mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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' }),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) }),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }),
|
||||
});
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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 })));
|
||||
|
||||
Reference in New Issue
Block a user