CloudWatch: use custom variable editor (#46943)

This commit is contained in:
Isabella Siu
2022-04-04 10:39:31 -04:00
committed by GitHub
parent c55be51f1e
commit f8d11fbef9
11 changed files with 732 additions and 108 deletions

View File

@@ -0,0 +1,141 @@
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { select } from 'react-select-event';
import { VariableQueryType } from '../../types';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
import { VariableQueryEditor, Props } from './VariableQueryEditor';
const defaultQuery = {
queryType: VariableQueryType.Regions,
namespace: '',
region: '',
metricName: '',
dimensionKey: '',
dimensionFilters: '',
ec2Filters: '',
instanceID: '',
attributeName: '',
resourceType: '',
tags: '',
refId: '',
};
const ds = setupMockedDataSource();
ds.datasource.getRegions = jest.fn().mockResolvedValue([
{ label: 'a1', value: 'a1' },
{ label: 'b1', value: 'b1' },
{ label: 'c1', value: 'c1' },
]);
ds.datasource.getNamespaces = jest.fn().mockResolvedValue([
{ label: 'x2', value: 'x2' },
{ label: 'y2', value: 'y2' },
{ label: 'z2', value: 'z2' },
]);
ds.datasource.getMetrics = jest.fn().mockResolvedValue([
{ label: 'h3', value: 'h3' },
{ label: 'i3', value: 'i3' },
{ label: 'j3', value: 'j3' },
]);
ds.datasource.getDimensionKeys = jest.fn().mockImplementation((namespace: string, region: string) => {
if (region === 'a1') {
return Promise.resolve([
{ label: 'q4', value: 'q4' },
{ label: 'r4', value: 'r4' },
{ label: 's4', value: 's4' },
]);
}
return Promise.resolve([{ label: 't4', value: 't4' }]);
});
ds.datasource.getVariables = jest.fn().mockReturnValue([]);
const defaultProps: Props = {
onChange: jest.fn(),
query: defaultQuery,
datasource: ds.datasource,
onRunQuery: () => {},
};
describe('VariableEditor', () => {
describe('and a new variable is created', () => {
it('should trigger a query using the first query type in the array', async () => {
const props = defaultProps;
props.query = defaultQuery;
render(<VariableQueryEditor {...props} />);
await waitFor(() => {
const querySelect = screen.queryByRole('combobox', { name: 'Query Type' });
expect(querySelect).toBeInTheDocument();
expect(screen.queryByText('Regions')).toBeInTheDocument();
// Should not render any fields besides Query Type
const regionSelect = screen.queryByRole('combobox', { name: 'Region' });
expect(regionSelect).not.toBeInTheDocument();
});
});
});
describe('and an existing variable is edited', () => {
it('should trigger new query using the saved query type', async () => {
const props = defaultProps;
props.query = {
...defaultQuery,
queryType: VariableQueryType.Metrics,
namespace: 'z2',
region: 'a1',
};
render(<VariableQueryEditor {...props} />);
await waitFor(() => {
const querySelect = screen.queryByRole('combobox', { name: 'Query Type' });
expect(querySelect).toBeInTheDocument();
expect(screen.queryByText('Metrics')).toBeInTheDocument();
const regionSelect = screen.queryByRole('combobox', { name: 'Region' });
expect(regionSelect).toBeInTheDocument();
expect(screen.queryByText('a1')).toBeInTheDocument();
const namespaceSelect = screen.queryByRole('combobox', { name: 'Namespace' });
expect(namespaceSelect).toBeInTheDocument();
expect(screen.queryByText('z2')).toBeInTheDocument();
// Should only render Query Type, Region, and Namespace selectors
const metricSelect = screen.queryByRole('combobox', { name: 'Metric' });
expect(metricSelect).not.toBeInTheDocument();
});
});
});
describe('and a different region is selected', () => {
it('should clear invalid fields', async () => {
const props = defaultProps;
props.query = {
...defaultQuery,
queryType: VariableQueryType.DimensionValues,
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionKey: 's4',
};
render(<VariableQueryEditor {...props} />);
const querySelect = screen.queryByLabelText('Query Type');
expect(querySelect).toBeInTheDocument();
expect(screen.queryByText('Dimension Values')).toBeInTheDocument();
const regionSelect = screen.getByRole('combobox', { name: 'Region' });
regionSelect.click();
await select(regionSelect, 'b1', {
container: document.body,
});
expect(ds.datasource.getMetrics).toHaveBeenCalledWith('z2', 'b1');
expect(ds.datasource.getDimensionKeys).toHaveBeenCalledWith('z2', 'b1');
expect(props.onChange).toHaveBeenCalledWith({
...defaultQuery,
refId: 'CloudWatchVariableQueryEditor-VariableQuery',
queryType: VariableQueryType.DimensionValues,
namespace: 'z2',
region: 'b1',
// metricName i3 exists in the new region and should not be removed
metricName: 'i3',
// dimensionKey s4 does not exist in the new region and should be removed
dimensionKey: '',
});
});
});
});

View File

@@ -0,0 +1,181 @@
import React from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { VariableTextField } from './VariableTextField';
import { CloudWatchDatasource } from '../../datasource';
import { useDimensionKeys, useMetrics, useNamespaces, useRegions } from '../../hooks';
import { CloudWatchJsonData, CloudWatchQuery, VariableQuery, VariableQueryType } from '../../types';
import { migrateVariableQuery } from '../../migrations';
import { VariableQueryField } from './VariableQueryField';
export type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData, VariableQuery>;
const queryTypes: Array<{ value: string; label: string }> = [
{ value: VariableQueryType.Regions, label: 'Regions' },
{ value: VariableQueryType.Namespaces, label: 'Namespaces' },
{ value: VariableQueryType.Metrics, label: 'Metrics' },
{ value: VariableQueryType.DimensionKeys, label: 'Dimension Keys' },
{ value: VariableQueryType.DimensionValues, label: 'Dimension Values' },
{ value: VariableQueryType.EBSVolumeIDs, label: 'EBS Volume IDs' },
{ value: VariableQueryType.EC2InstanceAttributes, label: 'EC2 Instance Attributes' },
{ value: VariableQueryType.ResourceArns, label: 'Resource ARNs' },
{ value: VariableQueryType.Statistics, label: 'Statistics' },
];
export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
const parsedQuery = migrateVariableQuery(query);
const { region, namespace, metricName, dimensionKey } = parsedQuery;
const [regions, regionIsLoading] = useRegions(datasource);
const namespaces = useNamespaces(datasource);
const metrics = useMetrics(datasource, region, namespace);
const dimensionKeys = useDimensionKeys(datasource, region, namespace, metricName);
const onRegionChange = async (region: string) => {
const validatedQuery = await sanitizeQuery({
...parsedQuery,
region,
});
onQueryChange(validatedQuery);
};
const onNamespaceChange = async (namespace: string) => {
const validatedQuery = await sanitizeQuery({
...parsedQuery,
namespace,
});
onQueryChange(validatedQuery);
};
const onQueryChange = (newQuery: VariableQuery) => {
onChange({ ...newQuery, refId: 'CloudWatchVariableQueryEditor-VariableQuery' });
};
// Reset dimensionValue parameters if namespace or region change
const sanitizeQuery = async (query: VariableQuery) => {
let { metricName, dimensionKey, dimensionFilters, namespace, region } = query;
if (metricName) {
await datasource.getMetrics(namespace, region).then((result: Array<SelectableValue<string>>) => {
if (!result.find((metric) => metric.value === metricName)) {
metricName = '';
dimensionFilters = '';
}
});
}
if (dimensionKey) {
await datasource.getDimensionKeys(namespace, region).then((result: Array<SelectableValue<string>>) => {
if (!result.find((key) => key.value === dimensionKey)) {
dimensionKey = '';
dimensionFilters = '';
}
});
}
return { ...query, metricName, dimensionKey, dimensionFilters };
};
const hasRegionField = [
VariableQueryType.Metrics,
VariableQueryType.DimensionKeys,
VariableQueryType.DimensionValues,
VariableQueryType.EBSVolumeIDs,
VariableQueryType.EC2InstanceAttributes,
VariableQueryType.ResourceArns,
].includes(parsedQuery.queryType);
const hasNamespaceField = [
VariableQueryType.Metrics,
VariableQueryType.DimensionKeys,
VariableQueryType.DimensionValues,
].includes(parsedQuery.queryType);
return (
<>
<VariableQueryField
value={parsedQuery.queryType}
options={queryTypes}
onChange={(value: VariableQueryType) => onQueryChange({ ...parsedQuery, queryType: value })}
label="Query Type"
/>
{hasRegionField && (
<VariableQueryField
value={region}
options={regions}
onChange={(value: string) => onRegionChange(value)}
label="Region"
isLoading={regionIsLoading}
/>
)}
{hasNamespaceField && (
<VariableQueryField
value={namespace}
options={namespaces}
onChange={(value: string) => onNamespaceChange(value)}
label="Namespace"
/>
)}
{parsedQuery.queryType === VariableQueryType.DimensionValues && (
<>
<VariableQueryField
value={metricName || null}
options={metrics}
onChange={(value: string) => onQueryChange({ ...parsedQuery, metricName: value })}
label="Metric"
/>
<VariableQueryField
value={dimensionKey || null}
options={dimensionKeys}
onChange={(value: string) => onQueryChange({ ...parsedQuery, dimensionKey: value })}
label="Dimension Key"
/>
<VariableTextField
value={query.dimensionFilters}
tooltip='A JSON object representing dimensions and the values to filter on. Ex. { "filter_name1": [ "filter_value1" ], "filter_name2": [ "*" ] }'
placeholder='{"key":["value"]}'
onBlur={(value: string) => onQueryChange({ ...parsedQuery, dimensionFilters: value })}
label="Filters"
/>
</>
)}
{parsedQuery.queryType === VariableQueryType.EBSVolumeIDs && (
<VariableTextField
value={query.instanceID}
placeholder="i-XXXXXXXXXXXXXXXXX"
onBlur={(value: string) => onQueryChange({ ...parsedQuery, instanceID: value })}
label="Instance ID"
/>
)}
{parsedQuery.queryType === VariableQueryType.EC2InstanceAttributes && (
<>
<VariableTextField
value={parsedQuery.attributeName}
placeholder="attribute name"
onBlur={(value: string) => onQueryChange({ ...parsedQuery, attributeName: value })}
label="Attribute Name"
/>
<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 })}
label="Filters"
/>
</>
)}
{parsedQuery.queryType === VariableQueryType.ResourceArns && (
<>
<VariableTextField
value={parsedQuery.resourceType}
placeholder="resource type"
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"
/>
</>
)}
</>
);
};

View File

@@ -0,0 +1,41 @@
import { SelectableValue } from '@grafana/data';
import { InlineField, Select } from '@grafana/ui';
import React from 'react';
import { VariableQueryType } from '../../types';
const LABEL_WIDTH = 20;
interface VariableQueryFieldProps<T> {
onChange: (value: T) => void;
options: SelectableValue[];
value: T | null;
label: string;
allowCustomValue?: boolean;
isLoading?: boolean;
inputId?: string;
}
export const VariableQueryField = <T extends string | VariableQueryType>({
label,
onChange,
value,
options,
allowCustomValue = false,
isLoading = false,
}: VariableQueryFieldProps<T>) => {
return (
<InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={'inline-field'}>
<Select
menuShouldPortal
aria-label={label}
width={25}
allowCustomValue={allowCustomValue}
value={value}
onChange={({ value }) => onChange(value!)}
options={options}
isLoading={isLoading}
inputId="inline-field"
/>
</InlineField>
);
};

View File

@@ -0,0 +1,29 @@
import { InlineField, Input } from '@grafana/ui';
import React, { FC, useState } from 'react';
const LABEL_WIDTH = 20;
const TEXT_WIDTH = 100;
interface VariableTextFieldProps {
onBlur: (value: string) => void;
placeholder: string;
value: string;
label: string;
tooltip?: string;
}
export const VariableTextField: FC<VariableTextFieldProps> = ({ label, onBlur, placeholder, value, tooltip }) => {
const [localValue, setLocalValue] = useState(value);
return (
<InlineField label={label} labelWidth={LABEL_WIDTH} tooltip={tooltip}>
<Input
aria-label={label}
placeholder={placeholder}
value={localValue}
onChange={(e) => setLocalValue(e.currentTarget.value)}
onBlur={() => onBlur(localValue)}
width={TEXT_WIDTH}
/>
</InlineField>
);
};

View File

@@ -0,0 +1 @@
export { VariableQueryEditor } from './VariableQueryEditor';