diff --git a/packages/grafana-ui/src/components/Forms/InlineField.tsx b/packages/grafana-ui/src/components/Forms/InlineField.tsx index c7806e649e7..a46ec3d642f 100644 --- a/packages/grafana-ui/src/components/Forms/InlineField.tsx +++ b/packages/grafana-ui/src/components/Forms/InlineField.tsx @@ -23,6 +23,8 @@ export interface Props extends Omit<FieldProps, 'css' | 'horizontal' | 'descript /** Error message to display */ error?: string | null; htmlFor?: string; + /** Make tooltip interactive */ + interactive?: boolean; } export const InlineField: FC<Props> = ({ @@ -38,6 +40,7 @@ export const InlineField: FC<Props> = ({ grow, error, transparent, + interactive, ...htmlProps }) => { const theme = useTheme2(); @@ -46,7 +49,13 @@ export const InlineField: FC<Props> = ({ const labelElement = typeof label === 'string' ? ( - <InlineLabel width={labelWidth} tooltip={tooltip} htmlFor={inputId} transparent={transparent}> + <InlineLabel + interactive={interactive} + width={labelWidth} + tooltip={tooltip} + htmlFor={inputId} + transparent={transparent} + > {label} </InlineLabel> ) : ( diff --git a/packages/grafana-ui/src/components/Forms/InlineLabel.tsx b/packages/grafana-ui/src/components/Forms/InlineLabel.tsx index 03f1045c109..32e21247969 100644 --- a/packages/grafana-ui/src/components/Forms/InlineLabel.tsx +++ b/packages/grafana-ui/src/components/Forms/InlineLabel.tsx @@ -23,6 +23,8 @@ export interface Props extends Omit<LabelProps, 'css' | 'description' | 'categor /** @deprecated */ /** This prop is deprecated and is not used anymore */ isInvalid?: boolean; + /** Make tooltip interactive */ + interactive?: boolean; /** @beta */ /** Controls which element the InlineLabel should be rendered into */ as?: React.ElementType; @@ -34,6 +36,7 @@ export const InlineLabel: FunctionComponent<Props> = ({ tooltip, width, transparent, + interactive, as: Component = 'label', ...rest }) => { @@ -43,7 +46,7 @@ export const InlineLabel: FunctionComponent<Props> = ({ <Component className={cx(styles.label, className)} {...rest}> {children} {tooltip && ( - <Tooltip placement="top" content={tooltip} theme="info"> + <Tooltip interactive={interactive} placement="top" content={tooltip} theme="info"> <Icon tabIndex={0} name="info-circle" size="sm" className={styles.icon} /> </Tooltip> )} diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts index 7cc2ab389e1..5ce973cca66 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts @@ -9,7 +9,11 @@ import { CustomVariableModel } from 'app/features/variables/types'; import { TemplateSrvMock } from '../../../../features/templating/template_srv.mock'; import { CloudWatchDatasource } from '../datasource'; -export function setupMockedDataSource({ data = [], variables }: { data?: any; variables?: any } = {}) { +export function setupMockedDataSource({ + data = [], + variables, + mockGetVariableName = true, +}: { data?: any; variables?: any; mockGetVariableName?: boolean } = {}) { let templateService = new TemplateSrvMock({ region: 'templatedRegion', fields: 'templatedField', @@ -19,7 +23,9 @@ export function setupMockedDataSource({ data = [], variables }: { data?: any; va templateService = new TemplateSrv(); templateService.init(variables); templateService.getVariables = jest.fn().mockReturnValue(variables); - templateService.getVariableName = (name: string) => name; + if (mockGetVariableName) { + templateService.getVariableName = (name: string) => name; + } } const datasource = new CloudWatchDatasource( diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx new file mode 100644 index 00000000000..b1050541aec --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx @@ -0,0 +1,109 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { MultiFilter } from './MultiFilter'; + +describe('MultiFilters', () => { + describe('when rendered with two existing multifilters', () => { + it('should render two filter items', async () => { + const filters = { + InstanceId: ['a', 'b'], + InstanceGroup: ['Group1'], + }; + const onChange = jest.fn(); + render(<MultiFilter filters={filters} onChange={onChange} />); + const filterItems = screen.getAllByTestId('cloudwatch-multifilter-item'); + expect(filterItems.length).toBe(2); + + expect(within(filterItems[0]).getByDisplayValue('InstanceId')).toBeInTheDocument(); + expect(within(filterItems[0]).getByDisplayValue('a, b')).toBeInTheDocument(); + + expect(within(filterItems[1]).getByDisplayValue('InstanceGroup')).toBeInTheDocument(); + expect(within(filterItems[1]).getByDisplayValue('Group1')).toBeInTheDocument(); + }); + }); + + describe('when adding a new filter item', () => { + it('it should add the new item but not call onChange', async () => { + const filters = {}; + const onChange = jest.fn(); + render(<MultiFilter filters={filters} onChange={onChange} />); + + await userEvent.click(screen.getByLabelText('Add')); + expect(screen.getByTestId('cloudwatch-multifilter-item')).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('when adding a new filter item with key', () => { + it('it should add the new item but not call onChange', async () => { + const filters = {}; + const onChange = jest.fn(); + render(<MultiFilter filters={filters} onChange={onChange} />); + + await userEvent.click(screen.getByLabelText('Add')); + const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item'); + expect(filterItemElement).toBeInTheDocument(); + + const keyElement = screen.getByTestId('cloudwatch-multifilter-item-key'); + expect(keyElement).toBeInTheDocument(); + await userEvent.type(keyElement!, 'my-key'); + fireEvent.blur(keyElement!); + + expect(within(filterItemElement).getByDisplayValue('my-key')).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('when adding a new filter item with key and value', () => { + it('it should add the new item and trigger onChange', async () => { + const filters = {}; + const onChange = jest.fn(); + render(<MultiFilter filters={filters} onChange={onChange} />); + + const label = await screen.findByLabelText('Add'); + await userEvent.click(label); + const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item'); + expect(filterItemElement).toBeInTheDocument(); + + const keyElement = screen.getByTestId('cloudwatch-multifilter-item-key'); + expect(keyElement).toBeInTheDocument(); + await userEvent.type(keyElement!, 'my-key'); + fireEvent.blur(keyElement!); + expect(within(filterItemElement).getByDisplayValue('my-key')).toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + + const valueElement = screen.getByTestId('cloudwatch-multifilter-item-value'); + expect(valueElement).toBeInTheDocument(); + await userEvent.type(valueElement!, 'my-value1,my-value2'); + fireEvent.blur(valueElement!); + expect(within(filterItemElement).getByDisplayValue('my-value1, my-value2')).toBeInTheDocument(); + expect(onChange).toHaveBeenCalledWith({ + 'my-key': ['my-value1', 'my-value2'], + }); + }); + }); + describe('when editing an existing filter item key', () => { + 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} />); + + const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item'); + expect(filterItemElement).toBeInTheDocument(); + expect(within(filterItemElement).getByDisplayValue('my-key')).toBeInTheDocument(); + expect(within(filterItemElement).getByDisplayValue('my-value')).toBeInTheDocument(); + + const keyElement = screen.getByTestId('cloudwatch-multifilter-item-key'); + expect(keyElement).toBeInTheDocument(); + await userEvent.type(keyElement!, '2'); + fireEvent.blur(keyElement!); + + expect(within(filterItemElement).getByDisplayValue('my-key2')).toBeInTheDocument(); + expect(onChange).toHaveBeenCalledWith({ + 'my-key2': ['my-value'], + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx new file mode 100644 index 00000000000..eddb3d2aedf --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx @@ -0,0 +1,68 @@ +import { isEqual } from 'lodash'; +import React, { useEffect, useState } from 'react'; + +import { EditorList } from '@grafana/experimental'; + +import { MultiFilters } from '../../types'; + +import { MultiFilterItem } from './MultiFilterItem'; + +export interface Props { + filters?: MultiFilters; + onChange: (filters: MultiFilters) => void; + keyPlaceholder?: string; +} + +export interface MultiFilterCondition { + key?: string; + operator?: string; + value?: string[]; +} + +const multiFiltersToFilterConditions = (filters: MultiFilters) => + Object.keys(filters).map((key) => ({ key, value: filters[key], operator: '=' })); + +const filterConditionsToMultiFilters = (filters: MultiFilterCondition[]) => { + const res: MultiFilters = {}; + filters.forEach(({ key, value }) => { + if (key && value) { + res[key] = value; + } + }); + return res; +}; + +export const MultiFilter: React.FC<Props> = ({ filters, onChange, keyPlaceholder }) => { + const [items, setItems] = useState<MultiFilterCondition[]>([]); + useEffect(() => setItems(filters ? multiFiltersToFilterConditions(filters) : []), [filters]); + const onFiltersChange = (newItems: Array<Partial<MultiFilterCondition>>) => { + setItems(newItems); + + // The onChange event should only be triggered in the case there is a complete dimension object. + // So when a new key is added that does not yet have a value, it should not trigger an onChange event. + const newMultifilters = filterConditionsToMultiFilters(newItems); + if (!isEqual(newMultifilters, filters)) { + onChange(newMultifilters); + } + }; + + return <EditorList items={items} onChange={onFiltersChange} renderItem={makeRenderFilter(keyPlaceholder)} />; +}; + +function makeRenderFilter(keyPlaceholder?: string) { + function renderFilter( + item: MultiFilterCondition, + onChange: (item: MultiFilterCondition) => void, + onDelete: () => void + ) { + return ( + <MultiFilterItem + filter={item} + onChange={(item) => onChange(item)} + onDelete={onDelete} + keyPlaceholder={keyPlaceholder} + /> + ); + } + return renderFilter; +} diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx new file mode 100644 index 00000000000..a4ccf64492c --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx @@ -0,0 +1,67 @@ +import { css, cx } from '@emotion/css'; +import React, { FunctionComponent, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { AccessoryButton, InputGroup } from '@grafana/experimental'; +import { Input, stylesFactory, useTheme2 } from '@grafana/ui'; + +import { MultiFilterCondition } from './MultiFilter'; + +export interface Props { + filter: MultiFilterCondition; + onChange: (value: MultiFilterCondition) => void; + onDelete: () => void; + keyPlaceholder?: string; +} + +export const MultiFilterItem: FunctionComponent<Props> = ({ filter, onChange, onDelete, keyPlaceholder }) => { + const [localKey, setLocalKey] = useState(filter.key || ''); + const [localValue, setLocalValue] = useState(filter.value?.join(', ') || ''); + const theme = useTheme2(); + const styles = getOperatorStyles(theme); + + return ( + <div data-testid="cloudwatch-multifilter-item"> + <InputGroup> + <Input + data-testid="cloudwatch-multifilter-item-key" + aria-label="Filter key" + value={localKey} + placeholder={keyPlaceholder ?? 'key'} + onChange={(e) => setLocalKey(e.currentTarget.value)} + onBlur={() => { + if (localKey && localKey !== filter.key) { + onChange({ ...filter, key: localKey }); + } + }} + /> + + <span className={cx(styles.root)}>=</span> + + <Input + data-testid="cloudwatch-multifilter-item-value" + aria-label="Filter value" + value={localValue} + placeholder="value1, value2,..." + onChange={(e) => setLocalValue(e.currentTarget.value)} + onBlur={() => { + const newValues = localValue.split(',').map((v) => v.trim()); + if (localValue && newValues !== filter.value) { + onChange({ ...filter, value: newValues }); + } + setLocalValue(newValues.join(', ')); + }} + /> + + <AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} type="button" /> + </InputGroup> + </div> + ); +}; + +const getOperatorStyles = stylesFactory((theme: GrafanaTheme2) => ({ + root: css({ + padding: theme.spacing(0, 1), + alignSelf: 'center', + }), +})); diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx index fffb71a724a..838c719fc1e 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx @@ -1,4 +1,5 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { select } from 'react-select-event'; @@ -13,11 +14,9 @@ const defaultQuery = { region: '', metricName: '', dimensionKey: '', - ec2Filters: '', instanceID: '', attributeName: '', resourceType: '', - tags: '', refId: '', }; @@ -40,7 +39,7 @@ ds.datasource.getMetrics = jest.fn().mockResolvedValue([ ]); ds.datasource.getDimensionKeys = jest .fn() - .mockImplementation((namespace: string, region: string, dimensionFilters?: Dimensions) => { + .mockImplementation((_namespace: string, region: string, dimensionFilters?: Dimensions) => { if (!!dimensionFilters) { return Promise.resolve([ { label: 's4', value: 's4' }, @@ -61,6 +60,7 @@ ds.datasource.getDimensionValues = jest.fn().mockResolvedValue([ { label: 'bar', value: 'bar' }, ]); ds.datasource.getVariables = jest.fn().mockReturnValue([]); +ds.datasource.getEc2InstanceAttribute = jest.fn().mockReturnValue([]); const onChange = jest.fn(); const defaultProps: Props = { @@ -167,6 +167,40 @@ describe('VariableEditor', () => { dimensionFilters: { v4: 'bar' }, }); }); + it('should parse multiFilters correctly', async () => { + const props = defaultProps; + props.query = { + ...defaultQuery, + queryType: VariableQueryType.EC2InstanceAttributes, + region: 'a1', + attributeName: 'Tags.blah', + ec2Filters: { s4: ['foo', 'bar'] }, + }; + render(<VariableQueryEditor {...props} />); + + await waitFor(() => { + expect(screen.getByDisplayValue('Tags.blah')).toBeInTheDocument(); + }); + + const filterItem = screen.getByTestId('cloudwatch-multifilter-item'); + expect(filterItem).toBeInTheDocument(); + expect(within(filterItem).getByDisplayValue('foo, bar')).toBeInTheDocument(); + + // set filter value + const valueElement = screen.getByTestId('cloudwatch-multifilter-item-value'); + expect(valueElement).toBeInTheDocument(); + await userEvent.type(valueElement!, ',baz'); + fireEvent.blur(valueElement!); + + expect(screen.getByDisplayValue('foo, bar, baz')).toBeInTheDocument(); + expect(onChange).toHaveBeenCalledWith({ + ...defaultQuery, + queryType: VariableQueryType.EC2InstanceAttributes, + region: 'a1', + attributeName: 'Tags.blah', + ec2Filters: { s4: ['foo', 'bar', 'baz'] }, + }); + }); }); describe('and a different region is selected', () => { it('should clear invalid fields', async () => { diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx index b7440e78ca9..0ccc64b7781 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx @@ -9,6 +9,7 @@ import { useDimensionKeys, useMetrics, useNamespaces, useRegions } from '../../h import { migrateVariableQuery } from '../../migrations'; import { CloudWatchJsonData, CloudWatchQuery, VariableQuery, VariableQueryType } from '../../types'; +import { MultiFilter } from './MultiFilter'; import { VariableQueryField } from './VariableQueryField'; import { VariableTextField } from './VariableTextField'; @@ -165,14 +166,44 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { placeholder="attribute name" onBlur={(value: string) => onQueryChange({ ...parsedQuery, attributeName: value })} label="Attribute Name" + interactive={true} + tooltip={ + <> + {'Attribute or tag to query on. Tags should be formatted "Tags.<name>". '} + <a + href="https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/template-queries-cloudwatch/#selecting-attributes" + target="_blank" + rel="noreferrer" + > + See the documentation for more details + </a> + </> + } /> - <VariableTextField - value={parsedQuery.ec2Filters} - tooltip='A JSON object representing dimensions/tags and the values to filter on. Ex. { "filter_name": [ "filter_value" ], "tag:name": [ "*" ] }' - placeholder='{"key":["value"]}' - onBlur={(value: string) => onQueryChange({ ...parsedQuery, ec2Filters: value })} + <InlineField label="Filters" - /> + labelWidth={20} + tooltip={ + <> + <a + href="https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/template-queries-cloudwatch/#selecting-attributes" + target="_blank" + rel="noreferrer" + > + Pre-defined ec2:DescribeInstances filters/tags + </a> + {' and the values to filter on. Tags should be formatted tag:<name>.'} + </> + } + > + <MultiFilter + filters={parsedQuery.ec2Filters} + onChange={(filters) => { + onChange({ ...parsedQuery, ec2Filters: filters }); + }} + keyPlaceholder="filter/tag" + /> + </InlineField> </> )} {parsedQuery.queryType === VariableQueryType.ResourceArns && ( @@ -183,12 +214,15 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { onBlur={(value: string) => onQueryChange({ ...parsedQuery, resourceType: value })} label="Resource Type" /> - <VariableTextField - value={parsedQuery.tags} - placeholder='{"tag":["value"]}' - onBlur={(value: string) => onQueryChange({ ...parsedQuery, tags: value })} - label="Tags" - /> + <InlineField label="Tags" labelWidth={20} tooltip="Tags to filter the returned values on."> + <MultiFilter + filters={parsedQuery.tags} + onChange={(filters) => { + onChange({ ...parsedQuery, tags: filters }); + }} + keyPlaceholder="tag" + /> + </InlineField> </> )} </> diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableTextField.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableTextField.tsx index 2e095bb4f00..693c9776e8b 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableTextField.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableTextField.tsx @@ -1,6 +1,6 @@ import React, { FC, useState } from 'react'; -import { InlineField, Input } from '@grafana/ui'; +import { InlineField, Input, PopoverContent } from '@grafana/ui'; const LABEL_WIDTH = 20; @@ -9,13 +9,21 @@ interface VariableTextFieldProps { placeholder: string; value: string; label: string; - tooltip?: string; + tooltip?: PopoverContent; + interactive?: boolean; } -export const VariableTextField: FC<VariableTextFieldProps> = ({ label, onBlur, placeholder, value, tooltip }) => { +export const VariableTextField: FC<VariableTextFieldProps> = ({ + interactive, + label, + onBlur, + placeholder, + value, + tooltip, +}) => { const [localValue, setLocalValue] = useState(value); return ( - <InlineField label={label} labelWidth={LABEL_WIDTH} tooltip={tooltip} grow> + <InlineField interactive={interactive} label={label} labelWidth={LABEL_WIDTH} tooltip={tooltip} grow> <Input aria-label={label} placeholder={placeholder} diff --git a/public/app/plugins/datasource/cloudwatch/datasource.test.ts b/public/app/plugins/datasource/cloudwatch/datasource.test.ts index c4baf75a911..e8f8619fd95 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.test.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.test.ts @@ -5,6 +5,7 @@ import { ArrayVector, DataFrame, dataFrameToJSON, dateTime, Field, MutableDataFr import { setDataSourceSrv } from '@grafana/runtime'; import { + dimensionVariable, labelsVariable, limitVariable, metricVariable, @@ -398,6 +399,19 @@ describe('datasource', () => { }); }); + describe('convertMultiFiltersFormat', () => { + const ds = setupMockedDataSource({ variables: [labelsVariable, dimensionVariable], mockGetVariableName: false }); + it('converts keys and values correctly', () => { + // the json in this line doesn't matter, but it makes sure that old queries will be parsed + const filters = { $dimension: ['b'], a: ['${labels:json}', 'bar'] }; + const result = ds.datasource.convertMultiFilterFormat(filters); + expect(result).toStrictEqual({ + env: ['b'], + a: ['InstanceId', 'InstanceType', 'bar'], + }); + }); + }); + describe('getLogGroupFields', () => { it('passes region correctly', async () => { const { datasource, fetchMock } = setupMockedDataSource(); diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 76e25387841..38eb2226efc 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -54,6 +54,7 @@ import { MetricQuery, MetricQueryType, MetricRequest, + MultiFilters, StartQueryRequest, TSDBResponse, } from './types'; @@ -125,7 +126,7 @@ export class CloudWatchDatasource this.logsTimeout = instanceSettings.jsonData.logsTimeout || '15m'; this.sqlCompletionItemProvider = new SQLCompletionItemProvider(this, this.templateSrv); this.metricMathCompletionItemProvider = new MetricMathCompletionItemProvider(this, this.templateSrv); - this.variables = new CloudWatchVariableSupport(this, this.templateSrv); + this.variables = new CloudWatchVariableSupport(this); this.annotations = CloudWatchAnnotationSupport; } @@ -756,7 +757,7 @@ export class CloudWatchDatasource return this.doMetricResourceRequest('ec2-instance-attribute', { region: this.templateSrv.replace(this.getActualRegion(region)), attributeName: this.templateSrv.replace(attributeName), - filters: JSON.stringify(filters), + filters: JSON.stringify(this.convertMultiFilterFormat(filters, 'filter key')), }); } @@ -764,7 +765,7 @@ export class CloudWatchDatasource return this.doMetricResourceRequest('resource-arns', { region: this.templateSrv.replace(this.getActualRegion(region)), resourceType: this.templateSrv.replace(resourceType), - tags: JSON.stringify(tags), + tags: JSON.stringify(this.convertMultiFilterFormat(tags, 'tag name')), }); } @@ -826,18 +827,40 @@ export class CloudWatchDatasource return { ...result, [key]: null }; } - const valueVar = this.templateSrv - .getVariables() - .find(({ name }) => name === this.templateSrv.getVariableName(value)); - if (valueVar) { - if ((valueVar as unknown as VariableWithMultiSupport).multi) { - const values = this.templateSrv.replace(value, scopedVars, 'pipe').split('|'); - return { ...result, [key]: values }; - } - return { ...result, [key]: [this.templateSrv.replace(value, scopedVars)] }; - } + const newValues = this.getVariableValue(value, scopedVars); + return { ...result, [key]: newValues }; + }, {}); + } - return { ...result, [key]: [value] }; + // get the value for a given template variable + getVariableValue(value: string, scopedVars: ScopedVars): string[] { + const variableName = this.templateSrv.getVariableName(value); + const valueVar = this.templateSrv.getVariables().find(({ name }) => { + return name === variableName; + }); + if (variableName && valueVar) { + if ((valueVar as unknown as VariableWithMultiSupport).multi) { + // rebuild the variable name to handle old migrated queries + const values = this.templateSrv.replace('$' + variableName, scopedVars, 'pipe').split('|'); + return values; + } + return [this.templateSrv.replace(value, scopedVars)]; + } + return [value]; + } + + convertMultiFilterFormat(multiFilters: MultiFilters, fieldName?: string) { + return Object.entries(multiFilters).reduce((result, [key, values]) => { + key = this.replace(key, {}, true, fieldName); + if (!values) { + return { ...result, [key]: null }; + } + const initialVal: string[] = []; + const newValues = values.reduce((result, value) => { + const vals = this.getVariableValue(value, {}); + return [...result, ...vals]; + }, initialVal); + return { ...result, [key]: newValues }; }, {}); } diff --git a/public/app/plugins/datasource/cloudwatch/migration.test.ts b/public/app/plugins/datasource/cloudwatch/migration.test.ts index 3d6ce76cea8..271fc54ca90 100644 --- a/public/app/plugins/datasource/cloudwatch/migration.test.ts +++ b/public/app/plugins/datasource/cloudwatch/migration.test.ts @@ -12,6 +12,7 @@ import { MetricEditorMode, MetricQueryType, VariableQueryType, + OldVariableQuery, } from './types'; describe('migration', () => { @@ -231,11 +232,45 @@ describe('migration', () => { }); describe('when resource_arns query is used', () => { it('should parse the query', () => { - const query = migrateVariableQuery('resource_arns(us-east-1,rds:db,{"environment":["$environment"]})'); + const query = migrateVariableQuery( + 'resource_arns(eu-west-1,elasticloadbalancing:loadbalancer,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})' + ); expect(query.queryType).toBe(VariableQueryType.ResourceArns); + expect(query.region).toBe('eu-west-1'); + expect(query.resourceType).toBe('elasticloadbalancing:loadbalancer'); + expect(query.tags).toStrictEqual({ 'elasticbeanstalk:environment-name': ['myApp-dev', 'myApp-prod'] }); + }); + }); + describe('when ec2_instance_attribute query is used', () => { + it('should parse the query', () => { + const query = migrateVariableQuery('ec2_instance_attribute(us-east-1,rds:db,{"environment":["$environment"]})'); + expect(query.queryType).toBe(VariableQueryType.EC2InstanceAttributes); expect(query.region).toBe('us-east-1'); - expect(query.resourceType).toBe('rds:db'); - expect(query.tags).toBe('{"environment":["$environment"]}'); + expect(query.attributeName).toBe('rds:db'); + expect(query.ec2Filters).toStrictEqual({ environment: ['$environment'] }); + }); + }); + describe('when OldVariableQuery is used', () => { + it('should parse the query', () => { + const oldQuery: OldVariableQuery = { + queryType: VariableQueryType.EC2InstanceAttributes, + namespace: '', + region: 'us-east-1', + metricName: '', + dimensionKey: '', + ec2Filters: '{"environment":["$environment"]}', + instanceID: '', + attributeName: 'rds:db', + resourceType: 'elasticloadbalancing:loadbalancer', + tags: '{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]}', + refId: '', + }; + const query = migrateVariableQuery(oldQuery); + expect(query.region).toBe('us-east-1'); + expect(query.attributeName).toBe('rds:db'); + expect(query.ec2Filters).toStrictEqual({ environment: ['$environment'] }); + expect(query.resourceType).toBe('elasticloadbalancing:loadbalancer'); + expect(query.tags).toStrictEqual({ 'elasticbeanstalk:environment-name': ['myApp-dev', 'myApp-prod'] }); }); }); }); diff --git a/public/app/plugins/datasource/cloudwatch/migrations.ts b/public/app/plugins/datasource/cloudwatch/migrations.ts index 1c13479fc94..88650a2246d 100644 --- a/public/app/plugins/datasource/cloudwatch/migrations.ts +++ b/public/app/plugins/datasource/cloudwatch/migrations.ts @@ -1,3 +1,5 @@ +import { omit } from 'lodash'; + import { AnnotationQuery, DataQuery } from '@grafana/data'; import { getNextRefIdChar } from 'app/core/utils/query'; @@ -8,6 +10,7 @@ import { MetricQueryType, VariableQuery, VariableQueryType, + OldVariableQuery, } from './types'; // Migrates a metric query that use more than one statistic into multiple queries @@ -70,10 +73,38 @@ export function migrateCloudWatchQuery(query: CloudWatchMetricsQuery) { } } -export function migrateVariableQuery(rawQuery: string | VariableQuery): VariableQuery { - if (typeof rawQuery !== 'string') { +function isVariableQuery(rawQuery: string | VariableQuery | OldVariableQuery): rawQuery is VariableQuery { + return typeof rawQuery !== 'string' && typeof rawQuery.ec2Filters !== 'string' && typeof rawQuery.tags !== 'string'; +} + +export function migrateVariableQuery(rawQuery: string | VariableQuery | OldVariableQuery): VariableQuery { + if (isVariableQuery(rawQuery)) { return rawQuery; } + + // rawQuery is OldVariableQuery + if (typeof rawQuery !== 'string') { + const newQuery: VariableQuery = omit(rawQuery, ['ec2Filters', 'tags']); + newQuery.ec2Filters = {}; + newQuery.tags = {}; + + if (rawQuery.ec2Filters !== '') { + try { + newQuery.ec2Filters = JSON.parse(rawQuery.ec2Filters); + } catch { + throw new Error(`unable to migrate poorly formed filters: ${rawQuery.ec2Filters}`); + } + } + if (rawQuery.tags !== '') { + try { + newQuery.tags = JSON.parse(rawQuery.tags); + } catch { + throw new Error(`unable to migrate poorly formed filters: ${rawQuery.tags}`); + } + } + return newQuery; + } + const newQuery: VariableQuery = { refId: 'CloudWatchVariableQueryEditor-VariableQuery', queryType: VariableQueryType.Regions, @@ -82,12 +113,13 @@ export function migrateVariableQuery(rawQuery: string | VariableQuery): Variable metricName: '', dimensionKey: '', dimensionFilters: {}, - ec2Filters: '', + ec2Filters: {}, instanceID: '', attributeName: '', resourceType: '', - tags: '', + tags: {}, }; + if (rawQuery === '') { return newQuery; } @@ -147,7 +179,13 @@ export function migrateVariableQuery(rawQuery: string | VariableQuery): Variable newQuery.queryType = VariableQueryType.EC2InstanceAttributes; newQuery.region = ec2InstanceAttributeQuery[1]; newQuery.attributeName = ec2InstanceAttributeQuery[2]; - newQuery.ec2Filters = ec2InstanceAttributeQuery[3] || ''; + if (ec2InstanceAttributeQuery[3]) { + try { + newQuery.ec2Filters = JSON.parse(ec2InstanceAttributeQuery[3]); + } catch { + throw new Error(`unable to migrate poorly formed filters: ${ec2InstanceAttributeQuery[3]}`); + } + } return newQuery; } @@ -156,7 +194,13 @@ export function migrateVariableQuery(rawQuery: string | VariableQuery): Variable newQuery.queryType = VariableQueryType.ResourceArns; newQuery.region = resourceARNsQuery[1]; newQuery.resourceType = resourceARNsQuery[2]; - newQuery.tags = resourceARNsQuery[3] || ''; + if (resourceARNsQuery[3]) { + try { + newQuery.tags = JSON.parse(resourceARNsQuery[3]); + } catch { + throw new Error(`unable to migrate poorly formed filters: ${resourceARNsQuery[3]}`); + } + } return newQuery; } diff --git a/public/app/plugins/datasource/cloudwatch/types.ts b/public/app/plugins/datasource/cloudwatch/types.ts index 38462070b93..e169e3fb329 100644 --- a/public/app/plugins/datasource/cloudwatch/types.ts +++ b/public/app/plugins/datasource/cloudwatch/types.ts @@ -11,6 +11,10 @@ export interface Dimensions { [key: string]: string | string[]; } +export interface MultiFilters { + [key: string]: string[]; +} + export type CloudWatchQueryMode = 'Metrics' | 'Logs' | 'Annotations'; export enum MetricQueryType { @@ -372,7 +376,7 @@ export enum VariableQueryType { Statistics = 'statistics', } -export interface VariableQuery extends DataQuery { +export interface OldVariableQuery extends DataQuery { queryType: VariableQueryType; namespace: string; region: string; @@ -386,6 +390,20 @@ export interface VariableQuery extends DataQuery { tags: string; } +export interface VariableQuery extends DataQuery { + queryType: VariableQueryType; + namespace: string; + region: string; + metricName: string; + dimensionKey: string; + dimensionFilters?: Dimensions; + ec2Filters?: MultiFilters; + instanceID: string; + attributeName: string; + resourceType: string; + tags?: MultiFilters; +} + export interface LegacyAnnotationQuery extends MetricStat, DataQuery { actionPrefix: string; alarmNamePrefix: string; diff --git a/public/app/plugins/datasource/cloudwatch/variables.test.ts b/public/app/plugins/datasource/cloudwatch/variables.test.ts index a6e48356153..57589b526dc 100644 --- a/public/app/plugins/datasource/cloudwatch/variables.test.ts +++ b/public/app/plugins/datasource/cloudwatch/variables.test.ts @@ -8,11 +8,9 @@ const defaultQuery: VariableQuery = { region: 'bar', metricName: '', dimensionKey: '', - ec2Filters: '', instanceID: '', attributeName: '', resourceType: '', - tags: '', refId: '', }; @@ -26,7 +24,7 @@ const getEbsVolumeIds = jest.fn().mockResolvedValue([{ label: 'f', value: 'f' }] const getEc2InstanceAttribute = jest.fn().mockResolvedValue([{ label: 'g', value: 'g' }]); const getResourceARNs = jest.fn().mockResolvedValue([{ label: 'h', value: 'h' }]); -const variables = new CloudWatchVariableSupport(ds.datasource, ds.templateService); +const variables = new CloudWatchVariableSupport(ds.datasource); describe('variables', () => { it('should run regions', async () => { @@ -114,7 +112,7 @@ describe('variables', () => { ...defaultQuery, queryType: VariableQueryType.EC2InstanceAttributes, attributeName: 'abc', - ec2Filters: '{"$dimension":["b"]}', + ec2Filters: { a: ['b'] }, }; beforeEach(() => { ds.datasource.getEc2InstanceAttribute = getEc2InstanceAttribute; @@ -129,7 +127,7 @@ describe('variables', () => { it('should run if instance id set', async () => { const result = await variables.execute(query); - expect(getEc2InstanceAttribute).toBeCalledWith(query.region, query.attributeName, { env: ['b'] }); + expect(getEc2InstanceAttribute).toBeCalledWith(query.region, query.attributeName, { a: ['b'] }); expect(result).toEqual([{ text: 'g', value: 'g', expandable: true }]); }); }); @@ -139,7 +137,7 @@ describe('variables', () => { ...defaultQuery, queryType: VariableQueryType.ResourceArns, resourceType: 'abc', - tags: '{"a":${labels:json}}', + tags: { a: ['b'] }, }; beforeEach(() => { ds.datasource.getResourceARNs = getResourceARNs; @@ -154,7 +152,7 @@ describe('variables', () => { it('should run if instance id set', async () => { const result = await variables.execute(query); - expect(getResourceARNs).toBeCalledWith(query.region, query.resourceType, { a: ['InstanceId', 'InstanceType'] }); + expect(getResourceARNs).toBeCalledWith(query.region, query.resourceType, { a: ['b'] }); expect(result).toEqual([{ text: 'h', value: 'h', expandable: true }]); }); }); diff --git a/public/app/plugins/datasource/cloudwatch/variables.ts b/public/app/plugins/datasource/cloudwatch/variables.ts index a4eb2ea5397..82733bb0861 100644 --- a/public/app/plugins/datasource/cloudwatch/variables.ts +++ b/public/app/plugins/datasource/cloudwatch/variables.ts @@ -2,7 +2,6 @@ import { from, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { CustomVariableSupport, DataQueryRequest, DataQueryResponse } from '@grafana/data'; -import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import { VariableQueryEditor } from './components/VariableQueryEditor/VariableQueryEditor'; import { CloudWatchDatasource } from './datasource'; @@ -11,12 +10,10 @@ import { VariableQuery, VariableQueryType } from './types'; export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchDatasource, VariableQuery> { private readonly datasource: CloudWatchDatasource; - private readonly templateSrv: TemplateSrv; - constructor(datasource: CloudWatchDatasource, templateSrv: TemplateSrv = getTemplateSrv()) { + constructor(datasource: CloudWatchDatasource) { super(); this.datasource = datasource; - this.templateSrv = templateSrv; this.query = this.query.bind(this); } @@ -125,11 +122,7 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD if (!attributeName) { return []; } - let filterJson = {}; - if (ec2Filters) { - filterJson = JSON.parse(this.templateSrv.replace(ec2Filters)); - } - const values = await this.datasource.getEc2InstanceAttribute(region, attributeName, filterJson); + const values = await this.datasource.getEc2InstanceAttribute(region, attributeName, ec2Filters ?? {}); return values.map((s: { label: string; value: string }) => ({ text: s.label, value: s.value, @@ -141,11 +134,7 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD if (!resourceType) { return []; } - let tagJson = {}; - if (tags) { - tagJson = JSON.parse(this.templateSrv.replace(tags)); - } - const keys = await this.datasource.getResourceARNs(region, resourceType, tagJson); + const keys = await this.datasource.getResourceARNs(region, resourceType, tags ?? {}); return keys.map((s: { label: string; value: string }) => ({ text: s.label, value: s.value,