CloudWatch: add generic filter component to variable editor (#47907)

* CloudWatch: add generic filter component to variable editor

* remove multi-text-select object

* remove hidden

* andres comments

* migration between 8.5 and this

* add waitFors to tests

* more await tweaks

* actually fix tests

* use popoverContent tooltip

* fix template variable handling

* prettier fix

* fix prettier 2

* feat: make tooltip links in query variable editor clickable

* fix template stuff

Co-authored-by: Adam Simpson <adam@adamsimpson.net>
This commit is contained in:
Isabella Siu 2022-04-29 16:42:59 -04:00 committed by GitHub
parent a96510d03c
commit 74c2c2ccf0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 528 additions and 69 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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