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