CloudWatch: Remove core imports from CloudWatchRequest (#81422)

* CloudWatch: Remove core imports from CloudWatchRequest and use appEvents for notifications

* add error message to variable query editor multi filters

* fix tests

* fix lint

* fix lint

* add test for non multi-variable

* pr comments
This commit is contained in:
Kevin Yu
2024-02-06 12:22:41 -08:00
committed by GitHub
parent a052dab7bc
commit 6f8852095e
16 changed files with 331 additions and 141 deletions

View File

@@ -95,12 +95,15 @@ describe('Cloudwatch SQLBuilderEditor', () => {
render(<SQLBuilderEditor {...baseProps} query={query} />);
await waitFor(() =>
expect(datasource.resources.getDimensionKeys).toHaveBeenCalledWith({
namespace: 'AWS/EC2',
region: query.region,
dimensionFilters: { InstanceId: null },
metricName: undefined,
})
expect(datasource.resources.getDimensionKeys).toHaveBeenCalledWith(
{
namespace: 'AWS/EC2',
region: query.region,
dimensionFilters: { InstanceId: null },
metricName: undefined,
},
false
)
);
expect(screen.getByText('AWS/EC2')).toBeInTheDocument();
expect(screen.getByLabelText('With schema')).toBeChecked();

View File

@@ -1,9 +1,10 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { SelectableValue, toOption } from '@grafana/data';
import { AccessoryButton, EditorList, InputGroup } from '@grafana/experimental';
import { Select } from '@grafana/ui';
import { Alert, Select, useStyles2 } from '@grafana/ui';
import { CloudWatchDatasource } from '../../../../datasource';
import {
@@ -11,7 +12,7 @@ import {
QueryEditorOperatorExpression,
QueryEditorPropertyType,
} from '../../../../expressions';
import { useDimensionKeys } from '../../../../hooks';
import { useDimensionKeys, useEnsureVariableHasSingleSelection } from '../../../../hooks';
import { COMPARISON_OPERATORS, EQUALS } from '../../../../language/cloudwatch-sql/language';
import { CloudWatchMetricsQuery } from '../../../../types';
import { appendTemplateVariables } from '../../../../utils/utils';
@@ -101,6 +102,7 @@ interface FilterItemProps {
const FilterItem = (props: FilterItemProps) => {
const { datasource, query, filter, onChange, onDelete } = props;
const styles = useStyles2(getStyles);
const sql = query.sql ?? {};
const namespace = getNamespaceFromExpression(sql.from);
@@ -127,36 +129,58 @@ const FilterItem = (props: FilterItemProps) => {
filter.property?.name,
]);
const propertyNameError = useEnsureVariableHasSingleSelection(datasource, filter.property?.name);
const operatorValueError = useEnsureVariableHasSingleSelection(
datasource,
typeof filter.operator?.value === 'string' ? filter.operator?.value : undefined
);
return (
<InputGroup>
<Select
width="auto"
value={filter.property?.name ? toOption(filter.property?.name) : null}
options={dimensionKeys}
allowCustomValue
onChange={({ value }) => value && onChange(setOperatorExpressionProperty(filter, value))}
/>
<div className={styles.container}>
<InputGroup>
<Select
width="auto"
value={filter.property?.name ? toOption(filter.property?.name) : null}
options={dimensionKeys}
allowCustomValue
onChange={({ value }) => value && onChange(setOperatorExpressionProperty(filter, value))}
/>
<Select
width="auto"
value={filter.operator?.name && toOption(filter.operator.name)}
options={OPERATORS}
onChange={({ value }) => value && onChange(setOperatorExpressionName(filter, value))}
/>
<Select
width="auto"
value={filter.operator?.name && toOption(filter.operator.name)}
options={OPERATORS}
onChange={({ value }) => value && onChange(setOperatorExpressionName(filter, value))}
/>
<Select
width="auto"
isLoading={state.loading}
value={
filter.operator?.value && typeof filter.operator?.value === 'string' ? toOption(filter.operator?.value) : null
}
options={state.value}
allowCustomValue
onOpenMenu={loadOptions}
onChange={({ value }) => value && onChange(setOperatorExpressionValue(filter, value))}
/>
<Select
width="auto"
isLoading={state.loading}
value={
filter.operator?.value && typeof filter.operator?.value === 'string'
? toOption(filter.operator?.value)
: null
}
options={state.value}
allowCustomValue
onOpenMenu={loadOptions}
onChange={({ value }) => value && onChange(setOperatorExpressionValue(filter, value))}
/>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
</InputGroup>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
</InputGroup>
{propertyNameError && (
<Alert className={styles.alert} title={propertyNameError} severity="error" topSpacing={1} />
)}
{operatorValueError && (
<Alert className={styles.alert} title={operatorValueError} severity="error" topSpacing={1} />
)}
</div>
);
};
const getStyles = () => ({
container: css({ display: 'inline-block' }),
alert: css({ minWidth: '100%', width: 'min-content' }),
});

View File

@@ -2,8 +2,14 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
import { MultiFilter } from './MultiFilter';
const ds = setupMockedDataSource({
variables: [],
});
describe('MultiFilters', () => {
describe('when rendered with two existing multifilters', () => {
it('should render two filter items', async () => {
@@ -12,7 +18,7 @@ describe('MultiFilters', () => {
InstanceGroup: ['Group1'],
};
const onChange = jest.fn();
render(<MultiFilter filters={filters} onChange={onChange} />);
render(<MultiFilter filters={filters} onChange={onChange} datasource={ds.datasource} />);
const filterItems = screen.getAllByTestId('cloudwatch-multifilter-item');
expect(filterItems.length).toBe(2);
@@ -28,7 +34,7 @@ describe('MultiFilters', () => {
it('it should add the new item but not call onChange', async () => {
const filters = {};
const onChange = jest.fn();
render(<MultiFilter filters={filters} onChange={onChange} />);
render(<MultiFilter filters={filters} onChange={onChange} datasource={ds.datasource} />);
await userEvent.click(screen.getByLabelText('Add'));
expect(screen.getByTestId('cloudwatch-multifilter-item')).toBeInTheDocument();
@@ -40,7 +46,7 @@ describe('MultiFilters', () => {
it('it should add the new item but not call onChange', async () => {
const filters = {};
const onChange = jest.fn();
render(<MultiFilter filters={filters} onChange={onChange} />);
render(<MultiFilter filters={filters} onChange={onChange} datasource={ds.datasource} />);
await userEvent.click(screen.getByLabelText('Add'));
const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item');
@@ -60,7 +66,7 @@ describe('MultiFilters', () => {
it('it should add the new item and trigger onChange', async () => {
const filters = {};
const onChange = jest.fn();
render(<MultiFilter filters={filters} onChange={onChange} />);
render(<MultiFilter filters={filters} onChange={onChange} datasource={ds.datasource} />);
const label = await screen.findByLabelText('Add');
await userEvent.click(label);
@@ -88,7 +94,7 @@ describe('MultiFilters', () => {
it('it should change the key and call onChange', async () => {
const filters = { 'my-key': ['my-value'] };
const onChange = jest.fn();
render(<MultiFilter filters={filters} onChange={onChange} />);
render(<MultiFilter filters={filters} onChange={onChange} datasource={ds.datasource} />);
const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item');
expect(filterItemElement).toBeInTheDocument();

View File

@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
import { EditorList } from '@grafana/experimental';
import { type CloudWatchDatasource } from '../../datasource';
import { MultiFilters } from '../../types';
import { MultiFilterItem } from './MultiFilterItem';
@@ -11,6 +12,7 @@ export interface Props {
filters?: MultiFilters;
onChange: (filters: MultiFilters) => void;
keyPlaceholder?: string;
datasource: CloudWatchDatasource;
}
export interface MultiFilterCondition {
@@ -32,7 +34,7 @@ const filterConditionsToMultiFilters = (filters: MultiFilterCondition[]) => {
return res;
};
export const MultiFilter = ({ filters, onChange, keyPlaceholder }: Props) => {
export const MultiFilter = ({ filters, onChange, keyPlaceholder, datasource }: Props) => {
const [items, setItems] = useState<MultiFilterCondition[]>([]);
useEffect(() => setItems(filters ? multiFiltersToFilterConditions(filters) : []), [filters]);
const onFiltersChange = (newItems: Array<Partial<MultiFilterCondition>>) => {
@@ -46,10 +48,12 @@ export const MultiFilter = ({ filters, onChange, keyPlaceholder }: Props) => {
}
};
return <EditorList items={items} onChange={onFiltersChange} renderItem={makeRenderFilter(keyPlaceholder)} />;
return (
<EditorList items={items} onChange={onFiltersChange} renderItem={makeRenderFilter(datasource, keyPlaceholder)} />
);
};
function makeRenderFilter(keyPlaceholder?: string) {
function makeRenderFilter(datasource: CloudWatchDatasource, keyPlaceholder?: string) {
function renderFilter(
item: MultiFilterCondition,
onChange: (item: MultiFilterCondition) => void,
@@ -61,6 +65,7 @@ function makeRenderFilter(keyPlaceholder?: string) {
onChange={(item) => onChange(item)}
onDelete={onDelete}
keyPlaceholder={keyPlaceholder}
datasource={datasource}
/>
);
}

View File

@@ -3,7 +3,10 @@ import React, { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { AccessoryButton, InputGroup } from '@grafana/experimental';
import { Input, useStyles2 } from '@grafana/ui';
import { Alert, Input, useStyles2 } from '@grafana/ui';
import { type CloudWatchDatasource } from '../../datasource';
import { useEnsureVariableHasSingleSelection } from '../../hooks';
import { MultiFilterCondition } from './MultiFilter';
@@ -12,11 +15,13 @@ export interface Props {
onChange: (value: MultiFilterCondition) => void;
onDelete: () => void;
keyPlaceholder?: string;
datasource: CloudWatchDatasource;
}
export const MultiFilterItem = ({ filter, onChange, onDelete, keyPlaceholder }: Props) => {
export const MultiFilterItem = ({ filter, onChange, onDelete, keyPlaceholder, datasource }: Props) => {
const [localKey, setLocalKey] = useState(filter.key || '');
const [localValue, setLocalValue] = useState(filter.value?.join(', ') || '');
const error = useEnsureVariableHasSingleSelection(datasource, filter.key);
const styles = useStyles2(getOperatorStyles);
return (
@@ -54,6 +59,7 @@ export const MultiFilterItem = ({ filter, onChange, onDelete, keyPlaceholder }:
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} type="button" />
</InputGroup>
{error && <Alert title={error} severity="error" topSpacing={1} />}
</div>
);
};

View File

@@ -152,12 +152,15 @@ describe('VariableEditor', () => {
select(keySelect, 'v4', {
container: document.body,
});
expect(ds.datasource.resources.getDimensionKeys).toHaveBeenCalledWith({
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionFilters: undefined,
});
expect(ds.datasource.resources.getDimensionKeys).toHaveBeenCalledWith(
{
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionFilters: undefined,
},
false
);
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith({
...defaultQuery,

View File

@@ -6,7 +6,14 @@ import { config } from '@grafana/runtime';
import { InlineField } from '@grafana/ui';
import { CloudWatchDatasource } from '../../datasource';
import { useAccountOptions, useDimensionKeys, useMetrics, useNamespaces, useRegions } from '../../hooks';
import {
useAccountOptions,
useDimensionKeys,
useMetrics,
useNamespaces,
useRegions,
useEnsureVariableHasSingleSelection,
} from '../../hooks';
import { migrateVariableQuery } from '../../migrations/variableQueryMigrations';
import { CloudWatchJsonData, CloudWatchQuery, VariableQuery, VariableQueryType } from '../../types';
import { ALL_ACCOUNTS_OPTION } from '../shared/Account';
@@ -43,6 +50,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
const metrics = useMetrics(datasource, { region, namespace });
const dimensionKeys = useDimensionKeys(datasource, { region, namespace, metricName });
const accountState = useAccountOptions(datasource.resources, query.region);
const dimensionKeyError = useEnsureVariableHasSingleSelection(datasource, dimensionKey);
const newFormStylingEnabled = config.featureToggles.awsDatasourcesNewFormStyling;
const onRegionChange = async (region: string) => {
@@ -179,6 +187,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
inputId={`variable-query-dimension-key-${query.refId}`}
allowCustomValue
newFormStylingEnabled={newFormStylingEnabled}
error={dimensionKeyError}
/>
{newFormStylingEnabled ? (
<EditorField label="Dimensions" className="width-30" tooltip="Dimensions to filter the returned values on">
@@ -263,6 +272,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
onChange({ ...parsedQuery, ec2Filters: filters });
}}
keyPlaceholder="filter/tag"
datasource={datasource}
/>
</EditorField>
) : (
@@ -289,6 +299,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
onChange({ ...parsedQuery, ec2Filters: filters });
}}
keyPlaceholder="filter/tag"
datasource={datasource}
/>
</InlineField>
)}
@@ -310,6 +321,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
onChange({ ...parsedQuery, tags: filters });
}}
keyPlaceholder="tag"
datasource={datasource}
/>
</EditorField>
) : (
@@ -320,6 +332,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
onChange({ ...parsedQuery, tags: filters });
}}
keyPlaceholder="tag"
datasource={datasource}
/>
</InlineField>
)}

View File

@@ -1,8 +1,9 @@
import { css } from '@emotion/css';
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { EditorField } from '@grafana/experimental';
import { InlineField, Select } from '@grafana/ui';
import { Alert, InlineField, Select, useStyles2 } from '@grafana/ui';
import { VariableQueryType } from '../../types';
import { removeMarginBottom } from '../styles';
@@ -18,6 +19,7 @@ interface VariableQueryFieldProps<T> {
allowCustomValue?: boolean;
isLoading?: boolean;
newFormStylingEnabled?: boolean;
error?: string;
}
export const VariableQueryField = <T extends string | VariableQueryType>({
@@ -29,31 +31,44 @@ export const VariableQueryField = <T extends string | VariableQueryType>({
isLoading = false,
inputId = label,
newFormStylingEnabled,
error,
}: VariableQueryFieldProps<T>) => {
const styles = useStyles2(getStyles);
return newFormStylingEnabled ? (
<EditorField label={label} htmlFor={inputId} className={removeMarginBottom}>
<Select
aria-label={label}
allowCustomValue={allowCustomValue}
value={value}
onChange={({ value }) => onChange(value!)}
options={options}
isLoading={isLoading}
inputId={inputId}
/>
</EditorField>
<>
<EditorField label={label} htmlFor={inputId} className={removeMarginBottom}>
<Select
aria-label={label}
allowCustomValue={allowCustomValue}
value={value}
onChange={({ value }) => onChange(value!)}
options={options}
isLoading={isLoading}
inputId={inputId}
/>
</EditorField>
{error && <Alert title={error} severity="error" topSpacing={1} />}
</>
) : (
<InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={inputId}>
<Select
aria-label={label}
width={25}
allowCustomValue={allowCustomValue}
value={value}
onChange={({ value }) => onChange(value!)}
options={options}
isLoading={isLoading}
inputId={inputId}
/>
</InlineField>
<>
<InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={inputId}>
<Select
aria-label={label}
width={25}
allowCustomValue={allowCustomValue}
value={value}
onChange={({ value }) => onChange(value!)}
options={options}
isLoading={isLoading}
inputId={inputId}
/>
</InlineField>
{error && <Alert className={styles.inlineFieldAlert} title={error} severity="error" topSpacing={1} />}
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
// width set to InlineField labelWidth + Select width + 0.5 for margin on the label
inlineFieldAlert: css({ maxWidth: theme.spacing(LABEL_WIDTH + 25 + 0.5) }),
});

View File

@@ -42,12 +42,15 @@ describe('Dimensions', () => {
/>
);
await userEvent.click(screen.getByLabelText('Dimensions filter key'));
expect(getDimensionKeys).toHaveBeenCalledWith({
namespace: q.namespace,
region: q.region,
metricName: q.metricName,
accountId: q.accountId,
dimensionFilters: { abc: ['xyz'] },
});
expect(getDimensionKeys).toHaveBeenCalledWith(
{
namespace: q.namespace,
region: q.region,
metricName: q.metricName,
accountId: q.accountId,
dimensionFilters: { abc: ['xyz'] },
},
false
);
});
});

View File

@@ -4,10 +4,10 @@ import { useAsyncFn } from 'react-use';
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { AccessoryButton, InputGroup } from '@grafana/experimental';
import { Select, useStyles2 } from '@grafana/ui';
import { Alert, Select, useStyles2 } from '@grafana/ui';
import { CloudWatchDatasource } from '../../../datasource';
import { useDimensionKeys } from '../../../hooks';
import { useDimensionKeys, useEnsureVariableHasSingleSelection } from '../../../hooks';
import { Dimensions, MetricStat } from '../../../types';
import { appendTemplateVariables } from '../../../utils/utils';
@@ -34,6 +34,7 @@ const excludeCurrentKey = (dimensions: Dimensions, currentKey: string | undefine
export const FilterItem = ({ filter, metricStat, datasource, disableExpressions, onChange, onDelete }: Props) => {
const { region, namespace, metricName, dimensions, accountId } = metricStat;
const error = useEnsureVariableHasSingleSelection(datasource, filter.key);
const dimensionsExcludingCurrentKey = useMemo(
() => excludeCurrentKey(dimensions ?? {}, filter.key),
[dimensions, filter]
@@ -76,7 +77,7 @@ export const FilterItem = ({ filter, metricStat, datasource, disableExpressions,
const styles = useStyles2(getOperatorStyles);
return (
<div data-testid="cloudwatch-dimensions-filter-item">
<div className={styles.container} data-testid="cloudwatch-dimensions-filter-item">
<InputGroup>
<Select
aria-label="Dimensions filter key"
@@ -111,6 +112,7 @@ export const FilterItem = ({ filter, metricStat, datasource, disableExpressions,
/>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} type="button" />
</InputGroup>
{error && <Alert className={styles.alert} title={error} severity="error" topSpacing={1} />}
</div>
);
};
@@ -120,4 +122,6 @@ const getOperatorStyles = (theme: GrafanaTheme2) => ({
padding: theme.spacing(0, 1),
alignSelf: 'center',
}),
container: css({ display: 'inline-block' }),
alert: css({ minWidth: '100%', width: 'min-content' }),
});

View File

@@ -11,7 +11,13 @@ import {
setupMockedDataSource,
} from './__mocks__/CloudWatchDataSource';
import { setupMockedResourcesAPI } from './__mocks__/ResourcesAPI';
import { useAccountOptions, useDimensionKeys, useIsMonitoringAccount, useMetrics } from './hooks';
import {
useAccountOptions,
useDimensionKeys,
useIsMonitoringAccount,
useMetrics,
useEnsureVariableHasSingleSelection,
} from './hooks';
const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
@@ -82,15 +88,18 @@ describe('hooks', () => {
);
await waitFor(() => {
expect(getDimensionKeysMock).toHaveBeenCalledTimes(1);
expect(getDimensionKeysMock).toHaveBeenCalledWith({
region: regionVariable.current.value,
namespace: namespaceVariable.current.value,
metricName: metricVariable.current.value,
accountId: accountIdVariable.current.value,
dimensionFilters: {
environment: [dimensionVariable.current.value],
expect(getDimensionKeysMock).toHaveBeenCalledWith(
{
region: regionVariable.current.value,
namespace: namespaceVariable.current.value,
metricName: metricVariable.current.value,
accountId: accountIdVariable.current.value,
dimensionFilters: {
environment: [dimensionVariable.current.value],
},
},
});
false
);
});
});
});
@@ -139,4 +148,35 @@ describe('hooks', () => {
});
});
});
describe('useEnsureVariableHasSingleSelection', () => {
it('should return an error if a variable has multiple options selected', () => {
const { datasource } = setupMockedDataSource();
datasource.resources.isVariableWithMultipleOptionsSelected = jest.fn().mockReturnValue(true);
const variable = '$variable';
const { result } = renderHook(() => useEnsureVariableHasSingleSelection(datasource, variable));
expect(result.current).toEqual(
`Template variables with multiple selected options are not supported for ${variable}`
);
});
it('should not return an error if a variable is a multi-variable but does not have multiple options selected', () => {
const { datasource } = setupMockedDataSource();
datasource.resources.isVariableWithMultipleOptionsSelected = jest.fn().mockReturnValue(false);
const variable = '$variable';
const { result } = renderHook(() => useEnsureVariableHasSingleSelection(datasource, variable));
expect(result.current).toEqual('');
});
it('should not return an error if a variable is not a multi-variable', () => {
const { datasource } = setupMockedDataSource();
datasource.resources.isMultiVariable = jest.fn().mockReturnValue(false);
const variable = '$variable';
const { result } = renderHook(() => useEnsureVariableHasSingleSelection(datasource, variable));
expect(result.current).toEqual('');
});
});
});

View File

@@ -87,13 +87,13 @@ export const useDimensionKeys = (
}
if (dimensionFilters) {
dimensionFilters = datasource.resources.convertDimensionFormat(dimensionFilters, {});
dimensionFilters = datasource.resources.convertDimensionFormat(dimensionFilters, {}, false);
}
// doing deep comparison to avoid making new api calls to list metrics unless dimension filter object props changes
useDeepCompareEffect(() => {
datasource.resources
.getDimensionKeys({ namespace, region, metricName, accountId, dimensionFilters })
.getDimensionKeys({ namespace, region, metricName, accountId, dimensionFilters }, false)
.then((result: Array<SelectableValue<string>>) => {
setDimensionKeys(appendTemplateVariables(datasource, result));
});
@@ -102,6 +102,28 @@ export const useDimensionKeys = (
return dimensionKeys;
};
export const useEnsureVariableHasSingleSelection = (datasource: CloudWatchDatasource, target?: string) => {
const [error, setError] = useState('');
// interpolate the target to ensure the check in useEffect runs when the variable selection is changed
const interpolatedTarget = datasource.templateSrv.replace(target);
useEffect(() => {
if (datasource.resources.isVariableWithMultipleOptionsSelected(target)) {
const newErrorMessage = `Template variables with multiple selected options are not supported for ${target}`;
if (error !== newErrorMessage) {
setError(newErrorMessage);
}
return;
}
if (error) {
setError('');
}
}, [datasource.resources, target, interpolatedTarget, error]);
return error;
};
export const useIsMonitoringAccount = (resources: ResourcesAPI, region: string) => {
const [isMonitoringAccount, setIsMonitoringAccount] = useState(false);
// we call this before the use effect to ensure dependency array below

View File

@@ -179,12 +179,15 @@ export class SQLCompletionItemProvider extends CompletionItemProvider {
dimensionFilters = (labelKeyTokens || []).reduce((acc, curr) => {
return { ...acc, [curr.value]: null };
}, {});
const keys = await this.resources.getDimensionKeys({
namespace: this.templateSrv.replace(namespaceToken.value.replace(/\"/g, '')),
region: this.templateSrv.replace(this.region),
metricName: metricNameToken?.value,
dimensionFilters,
});
const keys = await this.resources.getDimensionKeys(
{
namespace: this.templateSrv.replace(namespaceToken.value.replace(/\"/g, '')),
region: this.templateSrv.replace(this.region),
metricName: metricNameToken?.value,
dimensionFilters,
},
false
);
keys.map((m) => {
const key = /[\s\.-]/.test(m.value ?? '') ? `"${m.value}"` : m.value;
key && addSuggestion(key);

View File

@@ -773,8 +773,28 @@ describe('CloudWatchMetricsQueryRunner', () => {
beforeEach(() => {
const { runner, request, queryMock } = setupMockedMetricsQueryRunner({
variables: [
{ ...namespaceVariable, multi: true },
{ ...metricVariable, multi: true },
{
...namespaceVariable,
current: {
value: ['AWS/Redshift', 'AWS/EC2'],
text: ['AWS/Redshift', 'AWS/EC2'].toString(),
selected: true,
},
multi: true,
},
{
...metricVariable,
current: {
value: ['CPUUtilization', 'DroppedBytes'],
text: ['CPUUtilization', 'DroppedBytes'].toString(),
selected: true,
},
multi: true,
},
{
...dimensionVariable,
multi: true,
},
],
});
runner.debouncedCustomAlert = debouncedAlert;
@@ -789,7 +809,7 @@ describe('CloudWatchMetricsQueryRunner', () => {
metricName: '$' + metricVariable.name,
period: '',
alias: '',
dimensions: {},
dimensions: { [`$${dimensionVariable.name}`]: '' },
matchExact: true,
statistic: '',
refId: '',
@@ -802,7 +822,7 @@ describe('CloudWatchMetricsQueryRunner', () => {
queryMock
);
});
it('should show debounced alert for namespace and metric name', async () => {
it('should show debounced alert for namespace and metric name when multiple options are selected', async () => {
expect(debouncedAlert).toHaveBeenCalledWith(
'CloudWatch templating error',
'Multi template variables are not supported for namespace'
@@ -813,6 +833,13 @@ describe('CloudWatchMetricsQueryRunner', () => {
);
});
it('should not show debounced alert for a multi-variable if it only has one option selected', async () => {
expect(debouncedAlert).not.toHaveBeenCalledWith(
'CloudWatch templating error',
`Multi template variables are not supported for dimension keys`
);
});
it('should not show debounced alert for region', async () => {
expect(debouncedAlert).not.toHaveBeenCalledWith(
'CloudWatch templating error',

View File

@@ -1,11 +1,7 @@
import { Observable } from 'rxjs';
import { DataSourceInstanceSettings, DataSourceRef, getDataSourceRef, ScopedVars } from '@grafana/data';
import { BackendDataSourceResponse, FetchResponse, getBackendSrv, TemplateSrv } from '@grafana/runtime';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { store } from 'app/store/store';
import { AppNotificationTimeout } from 'app/types';
import { DataSourceInstanceSettings, DataSourceRef, getDataSourceRef, ScopedVars, AppEvents } from '@grafana/data';
import { BackendDataSourceResponse, FetchResponse, getBackendSrv, TemplateSrv, getAppEvents } from '@grafana/runtime';
import memoizedDebounce from '../memoizedDebounce';
import { CloudWatchJsonData, Dimensions, MetricRequest, MultiFilters } from '../types';
@@ -15,10 +11,7 @@ export abstract class CloudWatchRequest {
templateSrv: TemplateSrv;
ref: DataSourceRef;
dsQueryEndpoint = '/api/ds/query';
debouncedCustomAlert: (title: string, message: string) => void = memoizedDebounce(
displayCustomError,
AppNotificationTimeout.Error
);
debouncedCustomAlert: (title: string, message: string) => void = memoizedDebounce(displayCustomError);
constructor(
public instanceSettings: DataSourceInstanceSettings<CloudWatchJsonData>,
@@ -43,9 +36,18 @@ export abstract class CloudWatchRequest {
return getBackendSrv().fetch<BackendDataSourceResponse>(options);
}
convertDimensionFormat(dimensions: Dimensions, scopedVars: ScopedVars): Dimensions {
convertDimensionFormat(
dimensions: Dimensions,
scopedVars: ScopedVars,
displayErrorIfIsMultiTemplateVariable = true
): Dimensions {
return Object.entries(dimensions).reduce((result, [key, value]) => {
key = this.replaceVariableAndDisplayWarningIfMulti(key, scopedVars, true, 'dimension keys');
key = this.replaceVariableAndDisplayWarningIfMulti(
key,
scopedVars,
displayErrorIfIsMultiTemplateVariable,
'dimension keys'
);
if (Array.isArray(value)) {
return { ...result, [key]: value };
@@ -93,23 +95,35 @@ export abstract class CloudWatchRequest {
}, {});
}
isMultiVariable(target?: string) {
if (target) {
const variables = this.templateSrv.getVariables();
const variable = variables.find(({ name }) => name === getVariableName(target));
const type = variable?.type;
return (type === 'custom' || type === 'query' || type === 'datasource') && variable?.multi;
}
return false;
}
isVariableWithMultipleOptionsSelected(target?: string, scopedVars?: ScopedVars) {
if (!target || !this.isMultiVariable(target)) {
return false;
}
return this.expandVariableToArray(target, scopedVars || {}).length > 1;
}
replaceVariableAndDisplayWarningIfMulti(
target?: string,
scopedVars?: ScopedVars,
displayErrorIfIsMultiTemplateVariable?: boolean,
fieldName?: string
) {
if (displayErrorIfIsMultiTemplateVariable && !!target) {
const variables = this.templateSrv.getVariables();
const variable = variables.find(({ name }) => name === getVariableName(target));
const isMultiVariable =
variable?.type === 'custom' || variable?.type === 'query' || variable?.type === 'datasource';
if (isMultiVariable && variable.multi) {
this.debouncedCustomAlert(
'CloudWatch templating error',
`Multi template variables are not supported for ${fieldName || target}`
);
}
if (displayErrorIfIsMultiTemplateVariable && this.isVariableWithMultipleOptionsSelected(target)) {
this.debouncedCustomAlert(
'CloudWatch templating error',
`Multi template variables are not supported for ${fieldName || target}`
);
}
return this.templateSrv.replace(target, scopedVars);
@@ -128,4 +142,7 @@ export abstract class CloudWatchRequest {
}
const displayCustomError = (title: string, message: string) =>
store.dispatch(notifyApp(createErrorNotification(title, message)));
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [title, message],
});

View File

@@ -110,19 +110,18 @@ export class ResourcesAPI extends CloudWatchRequest {
}).then((metrics) => metrics.map((m) => ({ metricName: m.value.name, namespace: m.value.namespace })));
}
getDimensionKeys({
region,
namespace = '',
dimensionFilters = {},
metricName = '',
accountId,
}: GetDimensionKeysRequest): Promise<Array<SelectableValue<string>>> {
getDimensionKeys(
{ region, namespace = '', dimensionFilters = {}, metricName = '', accountId }: GetDimensionKeysRequest,
displayErrorIfIsMultiTemplateVariable?: boolean
): Promise<Array<SelectableValue<string>>> {
return this.memoizedGetRequest<Array<ResourceResponse<string>>>('dimension-keys', {
region: this.templateSrv.replace(this.getActualRegion(region)),
namespace: this.templateSrv.replace(namespace),
accountId: this.templateSrv.replace(accountId),
metricName: this.templateSrv.replace(metricName),
dimensionFilters: JSON.stringify(this.convertDimensionFormat(dimensionFilters, {})),
dimensionFilters: JSON.stringify(
this.convertDimensionFormat(dimensionFilters, {}, displayErrorIfIsMultiTemplateVariable)
),
}).then((r) => r.map((r) => ({ label: r.value, value: r.value })));
}
@@ -142,7 +141,7 @@ export class ResourcesAPI extends CloudWatchRequest {
region: this.templateSrv.replace(this.getActualRegion(region)),
namespace: this.templateSrv.replace(namespace),
metricName: this.templateSrv.replace(metricName.trim()),
dimensionKey: this.templateSrv.replace(dimensionKey),
dimensionKey: this.replaceVariableAndDisplayWarningIfMulti(dimensionKey, {}, true),
dimensionFilters: JSON.stringify(this.convertDimensionFormat(dimensionFilters, {})),
accountId: this.templateSrv.replace(accountId),
}).then((r) => r.map((r) => ({ label: r.value, value: r.value })));