CloudWatch: Add support for AWS Metric Insights (#42487)

* add support for code editor and builder

* refactor cloudwatch migration

* Add tooltip to editor field (#56)

* add tooltip

* add old tooltips

* Bug bash feedback fixes (#58)

* make ASC the default option

* update sql preview whenever sql changes

* don't allow queries without aggregation

* set default value for aggregation

* use new input field

* cleanup

* pr feedback

* prevent unnecessary rerenders

* use frame error instead of main error

* remove not used snapshot

* Use dimension filter in schema picker  (#63)

* use dimension key filter in group by and schema labels

* add dimension filter also to code editor

* add tests

* fix build error

* fix strict error

* remove debug code

* fix annotation editor (#64)

* fix annotation editor

* fix broken test

* revert annotation backend change

* PR feedback (#67)

* pr feedback

* removed dimension filter from group by

* add spacing between common fields and rest

* do not generate deep link for metric queries (#70)

* update docs (#69)

Co-authored-by: Erik Sundell <erik.sundell87@gmail.com>

* fix lint problem caused by merge conflict

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
This commit is contained in:
Erik Sundell
2021-11-30 10:53:31 +01:00
committed by GitHub
parent 2a50c029b2
commit bab78a9e64
86 changed files with 6487 additions and 1010 deletions

View File

@@ -1,10 +0,0 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { Alias } from './Alias';
describe('Alias', () => {
it('should render component', () => {
const tree = renderer.create(<Alias value={'legend'} onChange={() => {}} />).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -1,7 +1,6 @@
import React, { FunctionComponent, useState } from 'react';
import { debounce } from 'lodash';
import { LegacyForms } from '@grafana/ui';
const { Input } = LegacyForms;
import { Input } from '@grafana/ui';
export interface Props {
onChange: (alias: any) => void;
@@ -18,5 +17,5 @@ export const Alias: FunctionComponent<Props> = ({ value = '', onChange }) => {
propagateOnChange(e.target.value);
};
return <Input type="text" className="gf-form-input width-16" value={alias} onChange={onChange} />;
return <Input type="text" value={alias} onChange={onChange} />;
};

View File

@@ -0,0 +1,69 @@
import React from 'react';
import '@testing-library/jest-dom';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
import { CloudWatchAnnotationQuery } from '../types';
import { AnnotationQueryEditor } from './AnnotationQueryEditor';
import { act } from 'react-dom/test-utils';
const ds = setupMockedDataSource({
variables: [],
});
const q: CloudWatchAnnotationQuery = {
id: '',
region: 'us-east-2',
namespace: '',
period: '',
alias: '',
metricName: '',
dimensions: {},
matchExact: true,
statistic: '',
expression: '',
refId: '',
enable: true,
name: '',
iconColor: '',
prefixMatching: false,
actionPrefix: '',
alarmNamePrefix: '',
};
ds.datasource.getRegions = jest.fn().mockResolvedValue([]);
ds.datasource.getNamespaces = jest.fn().mockResolvedValue([]);
ds.datasource.getMetrics = jest.fn().mockResolvedValue([]);
ds.datasource.getDimensionKeys = jest.fn().mockResolvedValue([]);
ds.datasource.getVariables = jest.fn().mockReturnValue([]);
const props = {
datasource: ds.datasource,
query: q,
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
afterEach(cleanup);
describe('AnnotationQueryEditor', () => {
it('should not display match exact switch', () => {
render(<AnnotationQueryEditor {...props} />);
expect(screen.queryByText('Match exact')).toBeNull();
});
it('shoud not display wildcard option in dimension value dropdown', async () => {
ds.datasource.getDimensionValues = jest.fn().mockResolvedValue([[{ label: 'dimVal1', value: 'dimVal1' }]]);
props.query.dimensions = { instanceId: 'instance-123' };
render(<AnnotationQueryEditor {...props} />);
const valueElement = screen.getByText('instance-123');
expect(valueElement).toBeInTheDocument();
expect(screen.queryByText('*')).toBeNull();
act(async () => {
await valueElement.click();
await waitFor(() => {
expect(screen.queryByText('*')).toBeNull();
});
});
});
});

View File

@@ -1,10 +1,15 @@
import React, { ChangeEvent } from 'react';
import { LegacyForms } from '@grafana/ui';
const { Switch } = LegacyForms;
import { Switch, Input } from '@grafana/ui';
import { CloudWatchAnnotationQuery, CloudWatchMetricsQuery } from '../types';
import { PanelData } from '@grafana/data';
import { CloudWatchAnnotationQuery, CloudWatchQuery } from '../types';
import { CloudWatchDatasource } from '../datasource';
import { QueryField, PanelQueryEditor } from './';
import { MetricStatEditor } from './MetricStatEditor';
import EditorHeader from './ui/EditorHeader';
import InlineSelect from './ui/InlineSelect';
import { Space } from './ui/Space';
import { useRegions } from '../hooks';
import EditorRow from './ui/EditorRow';
import EditorField from './ui/EditorField';
export type Props = {
query: CloudWatchAnnotationQuery;
@@ -14,50 +19,69 @@ export type Props = {
};
export function AnnotationQueryEditor(props: React.PropsWithChildren<Props>) {
const { query, onChange } = props;
const { query, onChange, datasource } = props;
const [regions, regionIsLoading] = useRegions(datasource);
return (
<>
<PanelQueryEditor
{...props}
onChange={(editorQuery: CloudWatchQuery) => onChange({ ...query, ...editorQuery })}
onRunQuery={() => {}}
history={[]}
></PanelQueryEditor>
<div className="gf-form-inline">
<Switch
label="Enable Prefix Matching"
labelClass="query-keyword"
checked={query.prefixMatching}
onChange={() => onChange({ ...query, prefixMatching: !query.prefixMatching })}
<EditorHeader>
<InlineSelect
label="Region"
value={regions.find((v) => v.value === query.region)}
placeholder="Select region"
allowCustomValue
onChange={({ value: region }) => region && onChange({ ...query, region })}
options={regions}
isLoading={regionIsLoading}
/>
<div className="gf-form gf-form--grow">
<QueryField label="Action">
<input
disabled={!query.prefixMatching}
className="gf-form-input width-12"
value={query.actionPrefix || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange({ ...query, actionPrefix: event.target.value })
}
/>
</QueryField>
<QueryField label="Alarm Name">
<input
disabled={!query.prefixMatching}
className="gf-form-input width-12"
value={query.alarmNamePrefix || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange({ ...query, alarmNamePrefix: event.target.value })
}
/>
</QueryField>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
</div>
</EditorHeader>
<Space v={0.5} />
<MetricStatEditor
{...props}
disableExpressions={true}
onChange={(editorQuery: CloudWatchMetricsQuery) => onChange({ ...query, ...editorQuery })}
onRunQuery={() => {}}
></MetricStatEditor>
<Space v={0.5} />
<EditorRow>
<EditorField label="Period" width={26} tooltip="Minimum interval between points in seconds.">
<Input
value={query.period || ''}
placeholder="auto"
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange({ ...query, period: event.target.value })}
/>
</EditorField>
<EditorField label="Enable Prefix Matching" optional={true}>
<Switch
value={query.prefixMatching}
onChange={(e) => {
onChange({
...query,
prefixMatching: e.currentTarget.checked,
});
}}
/>
</EditorField>
<EditorField label="Action" optional={true}>
<Input
disabled={!query.prefixMatching}
value={query.actionPrefix || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange({ ...query, actionPrefix: event.target.value })
}
/>
</EditorField>
<EditorField label="Alarm Name" optional={true}>
<Input
disabled={!query.prefixMatching}
value={query.alarmNamePrefix || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange({ ...query, alarmNamePrefix: event.target.value })
}
/>
</EditorField>
</EditorRow>
</>
);
}

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import { Dimensions } from './';
import { SelectableStrings } from '../types';
describe('Dimensions', () => {
it('renders', () => {
mount(
<Dimensions
dimensions={{}}
onChange={(dimensions) => console.log(dimensions)}
loadKeys={() => Promise.resolve<SelectableStrings>([])}
loadValues={() => Promise.resolve<SelectableStrings>([])}
/>
);
});
describe('and no dimension were passed to the component', () => {
it('initially displays just an add button', () => {
const wrapper = shallow(
<Dimensions
dimensions={{}}
onChange={() => {}}
loadKeys={() => Promise.resolve<SelectableStrings>([])}
loadValues={() => Promise.resolve<SelectableStrings>([])}
/>
);
expect(wrapper.html()).toEqual(expect.stringContaining(`gf-form`));
});
});
describe('and one dimension key along with a value were passed to the component', () => {
it('initially displays the dimension key, value and an add button', () => {
const wrapper = shallow(
<Dimensions
dimensions={{ somekey: 'somevalue' }}
onChange={() => {}}
loadKeys={() => Promise.resolve<SelectableStrings>([])}
loadValues={() => Promise.resolve<SelectableStrings>([])}
/>
);
expect(wrapper.html()).toEqual(expect.stringContaining(`gf-form`));
});
});
});

View File

@@ -1,81 +0,0 @@
import React, { FunctionComponent, Fragment, useState, useEffect } from 'react';
import { isEqual } from 'lodash';
import { SelectableValue } from '@grafana/data';
import { SegmentAsync, Icon } from '@grafana/ui';
import { SelectableStrings } from '../types';
export interface Props {
dimensions: { [key: string]: string | string[] };
onChange: (dimensions: { [key: string]: string }) => void;
loadValues: (key: string) => Promise<SelectableStrings>;
loadKeys: () => Promise<SelectableStrings>;
}
const removeText = '-- remove dimension --';
const removeOption: SelectableValue<string> = { label: removeText, value: removeText };
// The idea of this component is that is should only trigger the onChange event in the case
// there is a complete dimension object. E.g, when a new key is added is doesn't have a value.
// That should not trigger onChange.
export const Dimensions: FunctionComponent<Props> = ({ dimensions, loadValues, loadKeys, onChange }) => {
const [data, setData] = useState(dimensions);
useEffect(() => {
const completeDimensions = Object.entries(data).reduce(
(res, [key, value]) => (value ? { ...res, [key]: value } : res),
{}
);
if (!isEqual(completeDimensions, dimensions)) {
onChange(completeDimensions);
}
}, [data, dimensions, onChange]);
const excludeUsedKeys = (options: SelectableStrings) => {
return options.filter(({ value }) => !Object.keys(data).includes(value!));
};
return (
<>
{Object.entries(data).map(([key, value], index) => (
<Fragment key={index}>
<SegmentAsync
allowCustomValue
value={key}
loadOptions={() => loadKeys().then((keys) => [removeOption, ...excludeUsedKeys(keys)])}
onChange={({ value: newKey }) => {
const { [key]: value, ...newDimensions } = data;
if (newKey === removeText) {
setData({ ...newDimensions });
} else {
setData({ ...newDimensions, [newKey!]: '' });
}
}}
/>
<label className="gf-form-label query-segment-operator">=</label>
<SegmentAsync
allowCustomValue
value={value}
placeholder="select dimension value"
loadOptions={() => loadValues(key)}
onChange={({ value: newValue }) => setData({ ...data, [key]: newValue! })}
/>
{Object.values(data).length > 1 && index + 1 !== Object.values(data).length && (
<label className="gf-form-label query-keyword">AND</label>
)}
</Fragment>
))}
{Object.values(data).every((v) => v) && (
<SegmentAsync
allowCustomValue
Component={
<a className="gf-form-label query-part">
<Icon name="plus" />
</a>
}
loadOptions={() => loadKeys().then(excludeUsedKeys)}
onChange={({ value: newKey }) => setData({ ...data, [newKey!]: '' })}
/>
)}
</>
);
};

View File

@@ -171,25 +171,26 @@ export class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogs
loadingLogGroups: true,
});
this.fetchLogGroupOptions(query.region).then((logGroups) => {
this.setState((state) => {
const selectedLogGroups = state.selectedLogGroups;
if (onChange) {
const nextQuery = {
...query,
logGroupNames: selectedLogGroups.map((group) => group.value!),
query.region &&
this.fetchLogGroupOptions(query.region).then((logGroups) => {
this.setState((state) => {
const selectedLogGroups = state.selectedLogGroups;
if (onChange) {
const nextQuery = {
...query,
logGroupNames: selectedLogGroups.map((group) => group.value!),
};
onChange(nextQuery);
}
return {
loadingLogGroups: false,
availableLogGroups: logGroups,
selectedLogGroups,
};
onChange(nextQuery);
}
return {
loadingLogGroups: false,
availableLogGroups: logGroups,
selectedLogGroups,
};
});
});
});
datasource.getRegions().then((regions) => {
this.setState({

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Input } from '@grafana/ui';
export interface Props {
onChange: (query: string) => void;
onRunQuery: () => void;
expression: string;
}
export function MathExpressionQueryField({ expression: query, onChange, onRunQuery }: React.PropsWithChildren<Props>) {
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && (event.shiftKey || event.ctrlKey)) {
event.preventDefault();
onRunQuery();
}
};
return (
<Input
name="Query"
value={query}
placeholder="Enter a math expression"
onBlur={onRunQuery}
onChange={(e) => onChange(e.currentTarget.value)}
onKeyDown={onKeyDown}
/>
);
}

View File

@@ -0,0 +1,49 @@
import React, { useMemo } from 'react';
import { MetadataInspectorProps } from '@grafana/data';
import { CloudWatchDatasource } from '../datasource';
import { CloudWatchQuery, CloudWatchJsonData } from '../types';
import { groupBy } from 'lodash';
export type Props = MetadataInspectorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>;
export function MetaInspector({ data = [] }: Props) {
const rows = useMemo(() => groupBy(data, 'refId'), [data]);
return (
<>
<table className="filter-table form-inline">
<thead>
<tr>
<th>RefId</th>
<th>Metric Data Query ID</th>
<th>Metric Data Query Expression</th>
<th>Period</th>
<th />
</tr>
</thead>
{Object.entries(rows).map(([refId, frames], idx) => {
if (!frames.length) {
return null;
}
const frame = frames[0];
const custom = frame.meta?.custom;
if (!custom) {
return null;
}
return (
<tbody key={idx}>
<tr>
<td>{refId}</td>
<td>{custom.id}</td>
<td>{frame.meta?.executedQueryString}</td>
<td>{custom.period}</td>
</tr>
</tbody>
);
})}
</table>
</>
);
}

View File

@@ -0,0 +1,122 @@
import React from 'react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
import '@testing-library/jest-dom';
import { CloudWatchMetricsQuery } from '../../types';
import userEvent from '@testing-library/user-event';
import { Dimensions } from '..';
import { within } from '@testing-library/dom';
const ds = setupMockedDataSource({
variables: [],
});
ds.datasource.getNamespaces = jest.fn().mockResolvedValue([]);
ds.datasource.getMetrics = jest.fn().mockResolvedValue([]);
ds.datasource.getDimensionKeys = jest.fn().mockResolvedValue([]);
ds.datasource.getVariables = jest.fn().mockReturnValue([]);
const q: CloudWatchMetricsQuery = {
id: '',
region: 'us-east-2',
namespace: '',
period: '',
alias: '',
metricName: '',
dimensions: {},
matchExact: true,
statistic: '',
expression: '',
refId: '',
};
const props = {
datasource: ds.datasource,
query: q,
disableExpressions: false,
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
afterEach(cleanup);
describe('Dimensions', () => {
describe('when rendered with two existing dimensions', () => {
it('should render two filter items', async () => {
props.query.dimensions = {
InstanceId: '*',
InstanceGroup: 'Group1',
};
render(<Dimensions {...props} query={props.query} dimensionKeys={[]} />);
const filterItems = screen.getAllByTestId('cloudwatch-dimensions-filter-item');
expect(filterItems.length).toBe(2);
expect(within(filterItems[0]).getByText('InstanceId')).toBeInTheDocument();
expect(within(filterItems[0]).getByText('*')).toBeInTheDocument();
expect(within(filterItems[1]).getByText('InstanceGroup')).toBeInTheDocument();
expect(within(filterItems[1]).getByText('Group1')).toBeInTheDocument();
});
});
describe('when adding a new filter item', () => {
it('it should add the new item but not call onChange', async () => {
props.query.dimensions = {};
const onChange = jest.fn();
render(<Dimensions {...props} query={props.query} onChange={onChange} dimensionKeys={[]} />);
userEvent.click(screen.getByLabelText('Add'));
expect(screen.getByTestId('cloudwatch-dimensions-filter-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 () => {
props.query.dimensions = {};
const onChange = jest.fn();
const { container } = render(
<Dimensions {...props} query={props.query} onChange={onChange} dimensionKeys={[]} />
);
userEvent.click(screen.getByLabelText('Add'));
const filterItemElement = screen.getByTestId('cloudwatch-dimensions-filter-item');
expect(filterItemElement).toBeInTheDocument();
const keyElement = container.querySelector('#cloudwatch-dimensions-filter-item-key');
expect(keyElement).toBeInTheDocument();
userEvent.type(keyElement!, 'my-key');
fireEvent.keyDown(keyElement!, { keyCode: 13 });
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 () => {
props.query.dimensions = {};
const onChange = jest.fn();
const { container } = render(
<Dimensions {...props} query={props.query} onChange={onChange} dimensionKeys={[]} />
);
userEvent.click(screen.getByLabelText('Add'));
const filterItemElement = screen.getByTestId('cloudwatch-dimensions-filter-item');
expect(filterItemElement).toBeInTheDocument();
const keyElement = container.querySelector('#cloudwatch-dimensions-filter-item-key');
expect(keyElement).toBeInTheDocument();
userEvent.type(keyElement!, 'my-key');
fireEvent.keyDown(keyElement!, { keyCode: 13 });
expect(onChange).not.toHaveBeenCalled();
const valueElement = container.querySelector('#cloudwatch-dimensions-filter-item-value');
expect(valueElement).toBeInTheDocument();
userEvent.type(valueElement!, 'my-value');
fireEvent.keyDown(valueElement!, { keyCode: 13 });
expect(onChange).not.toHaveBeenCalledWith({
...props.query,
dimensions: {
'my-key': 'my-value',
},
});
});
});
});

View File

@@ -0,0 +1,93 @@
import React, { useMemo, useState } from 'react';
import { isEqual } from 'lodash';
import { SelectableValue } from '@grafana/data';
import { Dimensions as DimensionsType, CloudWatchMetricsQuery } from '../../types';
import EditorList from '../ui/EditorList';
import { CloudWatchDatasource } from '../../datasource';
import { FilterItem } from './FilterItem';
export interface Props {
query: CloudWatchMetricsQuery;
onChange: (dimensions: DimensionsType) => void;
datasource: CloudWatchDatasource;
dimensionKeys: Array<SelectableValue<string>>;
disableExpressions: boolean;
}
export interface DimensionFilterCondition {
key?: string;
operator?: string;
value?: string;
}
const dimensionsToFilterConditions = (dimensions: DimensionsType | undefined) =>
Object.entries(dimensions ?? {}).reduce<DimensionFilterCondition[]>((acc, [key, value]) => {
if (value && typeof value === 'string') {
const filter = {
key,
value,
operator: '=',
};
return [...acc, filter];
}
return acc;
}, []);
const filterConditionsToDimensions = (filters: DimensionFilterCondition[]) => {
return filters.reduce<DimensionsType>((acc, { key, value }) => {
if (key && value) {
return { ...acc, [key]: value };
}
return acc;
}, {});
};
export const Dimensions: React.FC<Props> = ({ query, datasource, dimensionKeys, disableExpressions, onChange }) => {
const dimensionFilters = useMemo(() => dimensionsToFilterConditions(query.dimensions), [query.dimensions]);
const [items, setItems] = useState<DimensionFilterCondition[]>(dimensionFilters);
const onDimensionsChange = (newItems: Array<Partial<DimensionFilterCondition>>) => {
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 newDimensions = filterConditionsToDimensions(newItems);
if (!isEqual(newDimensions, query.dimensions)) {
onChange(newDimensions);
}
};
return (
<EditorList
items={items}
onChange={onDimensionsChange}
renderItem={makeRenderFilter(datasource, query, dimensionKeys, disableExpressions)}
/>
);
};
function makeRenderFilter(
datasource: CloudWatchDatasource,
query: CloudWatchMetricsQuery,
dimensionKeys: Array<SelectableValue<string>>,
disableExpressions: boolean
) {
function renderFilter(
item: DimensionFilterCondition,
onChange: (item: DimensionFilterCondition) => void,
onDelete: () => void
) {
return (
<FilterItem
filter={item}
onChange={(item) => onChange(item)}
datasource={datasource}
query={query}
disableExpressions={disableExpressions}
dimensionKeys={dimensionKeys}
onDelete={onDelete}
/>
);
}
return renderFilter;
}

View File

@@ -0,0 +1,110 @@
import React, { FunctionComponent, useMemo } from 'react';
import { css, cx } from '@emotion/css';
import { Select, stylesFactory, useTheme2 } from '@grafana/ui';
import { useAsyncFn } from 'react-use';
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { CloudWatchDatasource } from '../../datasource';
import { CloudWatchMetricsQuery, Dimensions } from '../../types';
import { appendTemplateVariables } from '../../utils/utils';
import { DimensionFilterCondition } from './Dimensions';
import InputGroup from '../ui/InputGroup';
import AccessoryButton from '../ui/AccessoryButton';
export interface Props {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
filter: DimensionFilterCondition;
dimensionKeys: Array<SelectableValue<string>>;
disableExpressions: boolean;
onChange: (value: DimensionFilterCondition) => void;
onDelete: () => void;
}
const wildcardOption = { value: '*', label: '*' };
const excludeCurrentKey = (dimensions: Dimensions, currentKey: string | undefined) =>
Object.entries(dimensions ?? {}).reduce<Dimensions>((acc, [key, value]) => {
if (key !== currentKey) {
return { ...acc, [key]: value };
}
return acc;
}, {});
export const FilterItem: FunctionComponent<Props> = ({
filter,
query: { region, namespace, metricName, dimensions },
datasource,
dimensionKeys,
disableExpressions,
onChange,
onDelete,
}) => {
const dimensionsExcludingCurrentKey = useMemo(() => excludeCurrentKey(dimensions ?? {}, filter.key), [
dimensions,
filter,
]);
const loadDimensionValues = async () => {
if (!filter.key) {
return [];
}
return datasource
.getDimensionValues(region, namespace, metricName, filter.key, dimensionsExcludingCurrentKey)
.then((result: Array<SelectableValue<string>>) => {
if (result.length && !disableExpressions) {
result.unshift(wildcardOption);
}
return appendTemplateVariables(datasource, result);
});
};
const [state, loadOptions] = useAsyncFn(loadDimensionValues, [filter.key, dimensions]);
const theme = useTheme2();
const styles = getOperatorStyles(theme);
return (
<div data-testid="cloudwatch-dimensions-filter-item">
<InputGroup>
<Select
inputId="cloudwatch-dimensions-filter-item-key"
width="auto"
value={filter.key ? toOption(filter.key) : null}
allowCustomValue
options={dimensionKeys}
onChange={(change) => {
if (change.label) {
onChange({ key: change.label, value: undefined });
}
}}
/>
<span className={cx(styles.root)}>=</span>
<Select
inputId="cloudwatch-dimensions-filter-item-value"
onOpenMenu={loadOptions}
width="auto"
value={filter.value ? toOption(filter.value) : null}
allowCustomValue
isLoading={state.loading}
options={state.value}
onChange={(change) => {
if (change.value) {
onChange({ ...filter, value: change.value });
}
}}
/>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
</InputGroup>
</div>
);
};
const getOperatorStyles = stylesFactory((theme: GrafanaTheme2) => ({
root: css({
padding: theme.spacing(0, 1),
alignSelf: 'center',
}),
}));

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
import '@testing-library/jest-dom';
import { CloudWatchMetricsQuery } from '../../types';
import userEvent from '@testing-library/user-event';
import { MetricStatEditor } from '..';
const ds = setupMockedDataSource({
variables: [],
});
ds.datasource.getNamespaces = jest.fn().mockResolvedValue([]);
ds.datasource.getMetrics = jest.fn().mockResolvedValue([]);
ds.datasource.getDimensionKeys = jest.fn().mockResolvedValue([]);
ds.datasource.getVariables = jest.fn().mockReturnValue([]);
const q: CloudWatchMetricsQuery = {
id: '',
region: 'us-east-2',
namespace: '',
period: '',
alias: '',
metricName: '',
dimensions: {},
matchExact: true,
statistic: '',
expression: '',
refId: '',
};
const props = {
datasource: ds.datasource,
query: q,
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
afterEach(cleanup);
describe('MetricStatEditor', () => {
describe('statistics field', () => {
test.each([['Average', 'p23.23', 'p34', '$statistic']])('should accept valid values', (statistic) => {
const onChange = jest.fn();
const onRunQuery = jest.fn();
props.datasource.getVariables = jest.fn().mockReturnValue(['$statistic']);
render(<MetricStatEditor {...props} onChange={onChange} onRunQuery={onRunQuery} />);
const statisticElement = screen.getByLabelText('Statistic');
expect(statisticElement).toBeInTheDocument();
userEvent.type(statisticElement!, statistic);
fireEvent.keyDown(statisticElement!, { keyCode: 13 });
expect(onChange).toHaveBeenCalledWith({ ...props.query, statistic });
expect(onRunQuery).toHaveBeenCalled();
});
test.each([['CustomStat', 'p23,23', '$statistic']])('should not accept invalid values', (statistic) => {
const onChange = jest.fn();
const onRunQuery = jest.fn();
render(<MetricStatEditor {...props} onChange={onChange} onRunQuery={onRunQuery} />);
const statisticElement = screen.getByLabelText('Statistic');
expect(statisticElement).toBeInTheDocument();
userEvent.type(statisticElement!, statistic);
fireEvent.keyDown(statisticElement!, { keyCode: 13 });
expect(onChange).not.toHaveBeenCalled();
expect(onRunQuery).not.toHaveBeenCalled();
});
});
describe('expressions', () => {
it('should display match exact switch is not set', () => {
render(<MetricStatEditor {...props} />);
expect(screen.getByText('Match exact')).toBeInTheDocument();
});
it('should display match exact switch if prop is set to false', () => {
render(<MetricStatEditor {...props} disableExpressions={false} />);
expect(screen.getByText('Match exact')).toBeInTheDocument();
});
it('should not display match exact switch if prop is set to true', async () => {
render(<MetricStatEditor {...props} disableExpressions={true} />);
expect(screen.queryByText('Match exact')).toBeNull();
});
});
});

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { Switch, Select } from '@grafana/ui';
import { CloudWatchMetricsQuery } from '../../types';
import { CloudWatchDatasource } from '../../datasource';
import EditorRows from '../ui/EditorRows';
import EditorRow from '../ui/EditorRow';
import EditorFieldGroup from '../ui/EditorFieldGroup';
import EditorField from '../ui/EditorField';
import { appendTemplateVariables, toOption } from '../../utils/utils';
import { useDimensionKeys, useMetrics, useNamespaces } from '../../hooks';
import { Dimensions } from '..';
export type Props = {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
disableExpressions?: boolean;
onChange: (value: CloudWatchMetricsQuery) => void;
onRunQuery: () => void;
};
export function MetricStatEditor({
query,
datasource,
disableExpressions = false,
onChange,
onRunQuery,
}: React.PropsWithChildren<Props>) {
const { region, namespace, metricName, dimensions } = query;
const namespaces = useNamespaces(datasource);
const metrics = useMetrics(datasource, region, namespace);
const dimensionKeys = useDimensionKeys(datasource, region, namespace, metricName, dimensions ?? {});
const onQueryChange = (query: CloudWatchMetricsQuery) => {
onChange(query);
onRunQuery();
};
return (
<EditorRows>
<EditorRow>
<EditorFieldGroup>
<EditorField label="Namespace" width={26}>
<Select
value={query.namespace}
allowCustomValue
options={namespaces}
onChange={({ value: namespace }) => {
if (namespace) {
onQueryChange({ ...query, namespace });
}
}}
/>
</EditorField>
<EditorField label="Metric name" width={16}>
<Select
value={query.metricName}
allowCustomValue
options={metrics}
onChange={({ value: metricName }) => {
if (metricName) {
onQueryChange({ ...query, metricName });
}
}}
/>
</EditorField>
<EditorField label="Statistic" width={16}>
<Select
inputId="metric-stat-editor-select-statistic"
allowCustomValue
value={toOption(query.statistic ?? datasource.standardStatistics[0])}
options={appendTemplateVariables(
datasource,
datasource.standardStatistics.filter((s) => s !== query.statistic).map(toOption)
)}
onChange={({ value: statistic }) => {
if (
!statistic ||
(!datasource.standardStatistics.includes(statistic) &&
!/^p\d{2}(?:\.\d{1,2})?$/.test(statistic) &&
!statistic.startsWith('$'))
) {
return;
}
onQueryChange({ ...query, statistic });
}}
/>
</EditorField>
</EditorFieldGroup>
</EditorRow>
<EditorRow>
<EditorField label="Dimensions">
<Dimensions
query={query}
onChange={(dimensions) => onQueryChange({ ...query, dimensions })}
dimensionKeys={dimensionKeys}
disableExpressions={disableExpressions}
datasource={datasource}
/>
</EditorField>
</EditorRow>
{!disableExpressions && (
<EditorRow>
<EditorField
label="Match exact"
optional={true}
tooltip="Only show metrics that exactly match all defined dimension names."
>
<Switch
checked={!!query.matchExact}
onChange={(e) => {
onQueryChange({
...query,
matchExact: e.currentTarget.checked,
});
}}
/>
</EditorField>
</EditorRow>
)}
</EditorRows>
);
}

View File

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

View File

@@ -1,13 +1,13 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { mount } from 'enzyme';
import { render, screen, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { DataSourceInstanceSettings } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { MetricsQueryEditor, normalizeQuery, Props } from './MetricsQueryEditor';
import { CloudWatchDatasource } from '../datasource';
import { CustomVariableModel, initialVariableModelState } from '../../../../features/variables/types';
import { CloudWatchJsonData } from '../types';
import { CloudWatchJsonData, CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from '../types';
const setup = () => {
const instanceSettings = {
@@ -35,6 +35,10 @@ const setup = () => {
const datasource = new CloudWatchDatasource(instanceSettings, templateSrv as any, {} as any);
datasource.metricFindQuery = async () => [{ value: 'test', label: 'test', text: 'test' }];
datasource.getNamespaces = jest.fn().mockResolvedValue([]);
datasource.getMetrics = jest.fn().mockResolvedValue([]);
datasource.getRegions = jest.fn().mockResolvedValue([]);
datasource.getDimensionKeys = jest.fn().mockResolvedValue([]);
const props: Props = {
query: {
@@ -50,6 +54,8 @@ const setup = () => {
expression: '',
alias: '',
matchExact: true,
metricQueryType: MetricQueryType.Search,
metricEditorMode: MetricEditorMode.Builder,
},
datasource,
history: [],
@@ -80,6 +86,8 @@ describe('QueryEditor', () => {
refId: '',
expression: '',
matchExact: true,
metricQueryType: MetricQueryType.Search,
metricEditorMode: MetricEditorMode.Builder,
} as any;
await act(async () => {
renderer.create(<MetricsQueryEditor {...props} />);
@@ -88,6 +96,7 @@ describe('QueryEditor', () => {
namespace: '',
metricName: '',
expression: '',
sqlExpression: '',
dimensions: {},
region: 'default',
id: '',
@@ -98,27 +107,18 @@ describe('QueryEditor', () => {
apiMode: 'Metrics',
refId: '',
matchExact: true,
metricQueryType: MetricQueryType.Search,
metricEditorMode: MetricEditorMode.Builder,
});
});
describe('should use correct default values', () => {
it('when region is null is display default in the label', async () => {
// @ts-ignore strict null error TS2345: Argument of type '() => Promise<void>' is not assignable to parameter of type '() => void | undefined'.
await act(async () => {
const props = setup();
props.query.region = (null as unknown) as string;
const wrapper = mount(<MetricsQueryEditor {...props} />);
expect(
wrapper.find('.gf-form-inline').first().find('Segment').find('InlineLabel').find('label').text()
).toEqual('default');
});
});
it('should normalize query with default values', () => {
expect(normalizeQuery({ refId: '42' } as any)).toEqual({
namespace: '',
metricName: '',
expression: '',
sqlExpression: '',
dimensions: {},
region: 'default',
id: '',
@@ -126,7 +126,89 @@ describe('QueryEditor', () => {
statistic: 'Average',
matchExact: true,
period: '',
queryMode: 'Metrics',
refId: '42',
metricQueryType: MetricQueryType.Search,
metricEditorMode: MetricEditorMode.Builder,
});
});
});
describe('should handle editor modes correctly', () => {
it('when metric query type is metric search and editor mode is builder', async () => {
await act(async () => {
const props = setup();
render(<MetricsQueryEditor {...props} />);
expect(screen.getByText('Metric Search')).toBeInTheDocument();
const radio = screen.getByLabelText('Builder');
expect(radio instanceof HTMLInputElement && radio.checked).toBeTruthy();
});
});
it('when metric query type is metric search and editor mode is raw', async () => {
await act(async () => {
const props = setup();
(props.query as CloudWatchMetricsQuery).metricEditorMode = MetricEditorMode.Code;
render(<MetricsQueryEditor {...props} />);
expect(screen.getByText('Metric Search')).toBeInTheDocument();
const radio = screen.getByLabelText('Code');
expect(radio instanceof HTMLInputElement && radio.checked).toBeTruthy();
});
});
it('when metric query type is metric query and editor mode is builder', async () => {
await act(async () => {
const props = setup();
(props.query as CloudWatchMetricsQuery).metricQueryType = MetricQueryType.Query;
(props.query as CloudWatchMetricsQuery).metricEditorMode = MetricEditorMode.Builder;
render(<MetricsQueryEditor {...props} />);
expect(screen.getByText('Metric Query')).toBeInTheDocument();
const radio = screen.getByLabelText('Builder');
expect(radio instanceof HTMLInputElement && radio.checked).toBeTruthy();
});
});
it('when metric query type is metric query and editor mode is raw', async () => {
await act(async () => {
const props = setup();
(props.query as CloudWatchMetricsQuery).metricQueryType = MetricQueryType.Query;
(props.query as CloudWatchMetricsQuery).metricEditorMode = MetricEditorMode.Code;
render(<MetricsQueryEditor {...props} />);
expect(screen.getByText('Metric Query')).toBeInTheDocument();
const radio = screen.getByLabelText('Code');
expect(radio instanceof HTMLInputElement && radio.checked).toBeTruthy();
});
});
});
describe('should handle expression options correctly', () => {
it('should display match exact switch', () => {
const props = setup();
render(<MetricsQueryEditor {...props} />);
expect(screen.getByText('Match exact')).toBeInTheDocument();
});
it('shoud display wildcard option in dimension value dropdown', async () => {
const props = setup();
props.datasource.getDimensionValues = jest.fn().mockResolvedValue([[{ label: 'dimVal1', value: 'dimVal1' }]]);
(props.query as CloudWatchMetricsQuery).metricQueryType = MetricQueryType.Search;
(props.query as CloudWatchMetricsQuery).metricEditorMode = MetricEditorMode.Builder;
(props.query as CloudWatchMetricsQuery).dimensions = { instanceId: 'instance-123' };
render(<MetricsQueryEditor {...props} />);
expect(screen.getByText('Match exact')).toBeInTheDocument();
const valueElement = screen.getByText('instance-123');
expect(valueElement).toBeInTheDocument();
expect(screen.queryByText('*')).toBeNull();
act(async () => {
await valueElement.click();
await waitFor(() => {
expect(screen.getByText('*')).toBeInTheDocument();
});
});
});
});

View File

@@ -1,27 +1,29 @@
import React, { PureComponent, ChangeEvent } from 'react';
import { QueryEditorProps, PanelData } from '@grafana/data';
import { LegacyForms, ValidationEvents, EventsWithValidation, Icon } from '@grafana/ui';
const { Input, Switch } = LegacyForms;
import { CloudWatchQuery, CloudWatchMetricsQuery, CloudWatchJsonData, ExecutedQueryPreview } from '../types';
import { QueryEditorProps } from '@grafana/data';
import { Input } from '@grafana/ui';
import {
CloudWatchQuery,
CloudWatchMetricsQuery,
CloudWatchJsonData,
MetricQueryType,
MetricEditorMode,
} from '../types';
import { CloudWatchDatasource } from '../datasource';
import { QueryField, Alias, MetricsQueryFieldsEditor } from './';
import { Alias, MetricStatEditor, MathExpressionQueryField, SQLBuilderEditor, SQLCodeEditor } from './';
import EditorRow from './ui/EditorRow';
import EditorField from './ui/EditorField';
import { Space } from './ui/Space';
import QueryHeader from './QueryHeader';
import { isMetricsQuery } from '../guards';
export type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>;
interface State {
showMeta: boolean;
sqlCodeEditorIsDirty: boolean;
}
const idValidationEvents: ValidationEvents = {
[EventsWithValidation.onBlur]: [
{
rule: (value) => new RegExp(/^$|^[a-z][a-zA-Z0-9_]*$/).test(value),
errorMessage: 'Invalid format. Only alphanumeric characters and underscores are allowed',
},
],
};
export const normalizeQuery = ({
namespace,
metricName,
@@ -32,169 +34,153 @@ export const normalizeQuery = ({
alias,
statistic,
period,
sqlExpression,
metricQueryType,
metricEditorMode,
...rest
}: CloudWatchMetricsQuery): CloudWatchMetricsQuery => {
const normalizedQuery = {
namespace: namespace || '',
metricName: metricName || '',
expression: expression || '',
dimensions: dimensions || {},
region: region || 'default',
id: id || '',
alias: alias || '',
queryMode: 'Metrics' as const,
namespace: namespace ?? '',
metricName: metricName ?? '',
expression: expression ?? '',
dimensions: dimensions ?? {},
region: region ?? 'default',
id: id ?? '',
alias: alias ?? '',
statistic: statistic ?? 'Average',
period: period || '',
period: period ?? '',
metricQueryType: metricQueryType ?? MetricQueryType.Search,
metricEditorMode: metricEditorMode ?? MetricEditorMode.Builder,
sqlExpression: sqlExpression ?? '',
...rest,
};
return !rest.hasOwnProperty('matchExact') ? { ...normalizedQuery, matchExact: true } : normalizedQuery;
};
export class MetricsQueryEditor extends PureComponent<Props, State> {
state: State = { showMeta: false };
state = {
sqlCodeEditorIsDirty: false,
};
componentDidMount(): void {
componentDidMount = () => {
const metricsQuery = this.props.query as CloudWatchMetricsQuery;
const query = normalizeQuery(metricsQuery);
this.props.onChange(query);
}
};
onChange(query: CloudWatchMetricsQuery) {
onChange = (query: CloudWatchQuery) => {
const { onChange, onRunQuery } = this.props;
onChange(query);
onRunQuery();
}
getExecutedQueryPreview(data?: PanelData): ExecutedQueryPreview {
if (!(data?.series.length && data?.series[0].meta?.custom)) {
return {
executedQuery: '',
period: '',
id: '',
};
}
return {
executedQuery: data?.series[0].meta.executedQueryString ?? '',
period: data.series[0].meta.custom['period'],
id: data.series[0].meta.custom['id'],
};
}
};
render() {
const { data, onRunQuery } = this.props;
const { onRunQuery, datasource } = this.props;
const metricsQuery = this.props.query as CloudWatchMetricsQuery;
const { showMeta } = this.state;
const query = normalizeQuery(metricsQuery);
const executedQueryPreview = this.getExecutedQueryPreview(data);
return (
<>
<MetricsQueryFieldsEditor {...{ ...this.props, query }}></MetricsQueryFieldsEditor>
<div className="gf-form-inline">
<div className="gf-form">
<QueryField
label="Id"
tooltip="Id can include numbers, letters, and underscore, and must start with a lowercase letter."
>
<Input
className="gf-form-input width-8"
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...metricsQuery, id: event.target.value })
}
validationEvents={idValidationEvents}
value={query.id}
<QueryHeader
query={query}
onRunQuery={onRunQuery}
datasource={datasource}
onChange={(newQuery) => {
if (isMetricsQuery(newQuery) && newQuery.metricEditorMode !== query.metricEditorMode) {
this.setState({ sqlCodeEditorIsDirty: false });
}
this.onChange(newQuery);
}}
sqlCodeEditorIsDirty={this.state.sqlCodeEditorIsDirty}
/>
<Space v={0.5} />
{query.metricQueryType === MetricQueryType.Search && (
<>
{query.metricEditorMode === MetricEditorMode.Builder && (
<MetricStatEditor {...{ ...this.props, query }}></MetricStatEditor>
)}
{query.metricEditorMode === MetricEditorMode.Code && (
<MathExpressionQueryField
onRunQuery={onRunQuery}
expression={query.expression ?? ''}
onChange={(expression) => this.props.onChange({ ...query, expression })}
></MathExpressionQueryField>
)}
</>
)}
{query.metricQueryType === MetricQueryType.Query && (
<>
{query.metricEditorMode === MetricEditorMode.Code && (
<SQLCodeEditor
region={query.region}
sql={query.sqlExpression ?? ''}
onChange={(sqlExpression) => {
if (!this.state.sqlCodeEditorIsDirty) {
this.setState({ sqlCodeEditorIsDirty: true });
}
this.props.onChange({ ...metricsQuery, sqlExpression });
}}
onRunQuery={onRunQuery}
datasource={datasource}
/>
</QueryField>
</div>
<div className="gf-form gf-form--grow">
<QueryField
className="gf-form--grow"
label="Expression"
tooltip="Optionally you can add an expression here. Please note that if a math expression that is referencing other queries is being used, it will not be possible to create an alert rule based on this query"
>
<Input
className="gf-form-input"
onBlur={onRunQuery}
value={query.expression || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...metricsQuery, expression: event.target.value })
}
/>
</QueryField>
</div>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<QueryField label="Period" tooltip="Minimum interval between points in seconds">
<Input
className="gf-form-input width-8"
value={query.period || ''}
placeholder="auto"
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...metricsQuery, period: event.target.value })
}
/>
</QueryField>
</div>
<div className="gf-form">
<QueryField
label="Alias"
tooltip="Alias replacement variables: {{metric}}, {{stat}}, {{namespace}}, {{region}}, {{period}}, {{label}}, {{YOUR_DIMENSION_NAME}}"
>
<Alias
value={metricsQuery.alias}
onChange={(value: string) => this.onChange({ ...metricsQuery, alias: value })}
/>
</QueryField>
<Switch
label="Match Exact"
labelClass="query-keyword"
tooltip="Only show metrics that exactly match all defined dimension names."
checked={metricsQuery.matchExact}
onChange={() =>
this.onChange({
...metricsQuery,
matchExact: !metricsQuery.matchExact,
})
)}
{query.metricEditorMode === MetricEditorMode.Builder && (
<>
<SQLBuilderEditor
query={query}
onChange={this.props.onChange}
onRunQuery={onRunQuery}
datasource={datasource}
></SQLBuilderEditor>
</>
)}
</>
)}
<Space v={0.5} />
<EditorRow>
<EditorField
label="ID"
width={26}
optional
tooltip="ID can be used to reference other queries in math expressions. The ID can include numbers, letters, and underscore, and must start with a lowercase letter."
>
<Input
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...metricsQuery, id: event.target.value })
}
type="text"
invalid={!!query.id && !/^$|^[a-z][a-zA-Z0-9_]*$/.test(query.id)}
value={query.id}
/>
</EditorField>
<EditorField label="Period" width={26} tooltip="Minimum interval between points in seconds.">
<Input
value={query.period || ''}
placeholder="auto"
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...metricsQuery, period: event.target.value })
}
/>
<label className="gf-form-label">
<a
onClick={() =>
executedQueryPreview &&
this.setState({
showMeta: !showMeta,
})
}
>
<Icon name={showMeta ? 'angle-down' : 'angle-right'} /> {showMeta ? 'Hide' : 'Show'} Query Preview
</a>
</label>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
{showMeta && (
<table className="filter-table form-inline">
<thead>
<tr>
<th>Metric Data Query ID</th>
<th>Metric Data Query Expression</th>
<th>Period</th>
<th />
</tr>
</thead>
<tbody>
<tr>
<td>{executedQueryPreview.id}</td>
<td>{executedQueryPreview.executedQuery}</td>
<td>{executedQueryPreview.period}</td>
</tr>
</tbody>
</table>
)}
</div>
</EditorField>
<EditorField
label="Alias"
width={26}
optional
tooltip="Change time series legend name using this field. See documentation for replacement variable formats."
>
<Alias
value={metricsQuery.alias ?? ''}
onChange={(value: string) => this.onChange({ ...metricsQuery, alias: value })}
/>
</EditorField>
</EditorRow>
</>
);
}

View File

@@ -1,157 +0,0 @@
import React, { useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { Segment, SegmentAsync } from '@grafana/ui';
import { CloudWatchMetricsQuery, SelectableStrings } from '../types';
import { CloudWatchDatasource } from '../datasource';
import { Dimensions, QueryInlineField } from '.';
export type Props = {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
onRunQuery?: () => void;
onChange: (value: CloudWatchMetricsQuery) => void;
};
interface State {
regions: SelectableStrings;
namespaces: SelectableStrings;
metricNames: SelectableStrings;
variableOptionGroup: SelectableValue<string>;
showMeta: boolean;
}
export function MetricsQueryFieldsEditor({
query,
datasource,
onChange,
onRunQuery = () => {},
}: React.PropsWithChildren<Props>) {
const metricsQuery = query as CloudWatchMetricsQuery;
const [state, setState] = useState<State>({
regions: [],
namespaces: [],
metricNames: [],
variableOptionGroup: {},
showMeta: false,
});
useEffect(() => {
const variableOptionGroup = {
label: 'Template Variables',
options: datasource.getVariables().map(toOption),
};
Promise.all([datasource.metricFindQuery('regions()'), datasource.metricFindQuery('namespaces()')]).then(
([regions, namespaces]) => {
setState((prevState) => ({
...prevState,
regions: [...regions, variableOptionGroup],
namespaces: [...namespaces, variableOptionGroup],
variableOptionGroup,
}));
}
);
}, [datasource]);
const loadMetricNames = async () => {
const { namespace, region } = query;
return datasource.metricFindQuery(`metrics(${namespace},${region})`).then(appendTemplateVariables);
};
const appendTemplateVariables = (values: SelectableValue[]) => [
...values,
{ label: 'Template Variables', options: datasource.getVariables().map(toOption) },
];
const toOption = (value: any) => ({ label: value, value });
const onQueryChange = (query: CloudWatchMetricsQuery) => {
onChange(query);
onRunQuery();
};
// Load dimension values based on current selected dimensions.
// Remove the new dimension key and all dimensions that has a wildcard as selected value
const loadDimensionValues = (newKey: string) => {
const { [newKey]: value, ...dim } = metricsQuery.dimensions;
const newDimensions = Object.entries(dim).reduce(
(result, [key, value]) => (value === '*' ? result : { ...result, [key]: value }),
{}
);
return datasource
.getDimensionValues(query.region, query.namespace, metricsQuery.metricName, newKey, newDimensions)
.then((values) => (values.length ? [{ value: '*', text: '*', label: '*' }, ...values] : values))
.then(appendTemplateVariables);
};
const { regions, namespaces, variableOptionGroup } = state;
return (
<>
<QueryInlineField label="Region">
<Segment
value={query.region}
placeholder="Select region"
options={regions}
allowCustomValue
onChange={({ value: region }) => onQueryChange({ ...query, region: region! })}
/>
</QueryInlineField>
{query.expression?.length === 0 && (
<>
<QueryInlineField label="Namespace">
<Segment
value={query.namespace}
placeholder="Select namespace"
allowCustomValue
options={namespaces}
onChange={({ value: namespace }) => onQueryChange({ ...query, namespace: namespace! })}
/>
</QueryInlineField>
<QueryInlineField label="Metric Name">
<SegmentAsync
value={metricsQuery.metricName}
placeholder="Select metric name"
allowCustomValue
loadOptions={loadMetricNames}
onChange={({ value: metricName }) => onQueryChange({ ...metricsQuery, metricName })}
/>
</QueryInlineField>
<QueryInlineField label="Statistic">
<Segment
allowCustomValue
value={query.statistic}
options={[
...datasource.standardStatistics.filter((s) => s !== query.statistic).map(toOption),
variableOptionGroup,
]}
onChange={({ value: statistic }) => {
if (
!datasource.standardStatistics.includes(statistic) &&
!/^p\d{2}(?:\.\d{1,2})?$/.test(statistic) &&
!statistic.startsWith('$')
) {
return;
}
onQueryChange({ ...metricsQuery, statistic });
}}
/>
</QueryInlineField>
<QueryInlineField label="Dimensions">
<Dimensions
dimensions={metricsQuery.dimensions}
onChange={(dimensions) => onQueryChange({ ...metricsQuery, dimensions })}
loadKeys={() => datasource.getDimensionKeys(query.namespace, query.region).then(appendTemplateVariables)}
loadValues={loadDimensionValues}
/>
</QueryInlineField>
</>
)}
</>
);
}

View File

@@ -22,33 +22,36 @@ export class PanelQueryEditor extends PureComponent<Props> {
return (
<>
<QueryInlineField label="Query Mode">
<Segment
value={apiModes[apiMode]}
options={Object.values(apiModes)}
onChange={({ value }) => {
const newMode = (value as 'Metrics' | 'Logs') ?? 'Metrics';
if (newMode !== apiModes[apiMode].value) {
const commonProps = pick(
query,
'id',
'region',
'namespace',
'refId',
'hide',
'key',
'queryType',
'datasource'
);
{/* TODO: Remove this in favor of the QueryHeader */}
{apiMode === ExploreMode.Logs && (
<QueryInlineField label="Query Mode">
<Segment
value={apiModes[apiMode]}
options={Object.values(apiModes)}
onChange={({ value }) => {
const newMode = (value as 'Metrics' | 'Logs') ?? 'Metrics';
if (newMode !== apiModes[apiMode].value) {
const commonProps = pick(
query,
'id',
'region',
'namespace',
'refId',
'hide',
'key',
'queryType',
'datasource'
);
this.props.onChange({
...commonProps,
queryMode: newMode,
} as CloudWatchQuery);
}
}}
/>
</QueryInlineField>
this.props.onChange({
...commonProps,
queryMode: newMode,
} as CloudWatchQuery);
}
}}
/>
</QueryInlineField>
)}
{apiMode === ExploreMode.Logs ? (
<LogsQueryEditor {...this.props} allowCustomValue />
) : (

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from '../types';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
import QueryHeader from './QueryHeader';
const ds = setupMockedDataSource({
variables: [],
});
ds.datasource.getRegions = jest.fn().mockResolvedValue([]);
const query: CloudWatchMetricsQuery = {
id: '',
region: 'us-east-2',
namespace: '',
period: '',
alias: '',
metricName: '',
dimensions: {},
matchExact: true,
statistic: '',
expression: '',
refId: '',
};
describe('QueryHeader', () => {
describe('confirm modal', () => {
it('should be shown when moving from code editor to builder when in sql mode', async () => {
const onChange = jest.fn();
const onRunQuery = jest.fn();
query.metricEditorMode = MetricEditorMode.Code;
query.metricQueryType = MetricQueryType.Query;
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={ds.datasource}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
/>
);
const builderElement = screen.getByLabelText('Builder');
expect(builderElement).toBeInTheDocument();
await act(async () => {
await builderElement.click();
});
const modalTitleElem = screen.getByText('Are you sure?');
expect(modalTitleElem).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it('should not be shown when moving from builder to code when in sql mode', async () => {
const onChange = jest.fn();
const onRunQuery = jest.fn();
query.metricEditorMode = MetricEditorMode.Builder;
query.metricQueryType = MetricQueryType.Query;
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={ds.datasource}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
/>
);
const builderElement = screen.getByLabelText('Code');
expect(builderElement).toBeInTheDocument();
await act(async () => {
await builderElement.click();
});
const modalTitleElem = screen.queryByText('Are you sure?');
expect(modalTitleElem).toBeNull();
expect(onChange).toHaveBeenCalled();
});
it('should not be shown when moving from code to builder when in standard mode', async () => {
const onChange = jest.fn();
const onRunQuery = jest.fn();
query.metricEditorMode = MetricEditorMode.Code;
query.metricQueryType = MetricQueryType.Search;
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={ds.datasource}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
/>
);
const builderElement = screen.getByLabelText('Builder');
expect(builderElement).toBeInTheDocument();
await act(async () => {
await builderElement.click();
});
const modalTitleElem = screen.queryByText('Are you sure?');
expect(modalTitleElem).toBeNull();
expect(onChange).toHaveBeenCalled();
});
});
it('run button should be displayed in code editor in metric query mode', async () => {
const onChange = jest.fn();
const onRunQuery = jest.fn();
query.metricEditorMode = MetricEditorMode.Code;
query.metricQueryType = MetricQueryType.Query;
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={ds.datasource}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
/>
);
const runQueryButton = screen.getByText('Run query');
expect(runQueryButton).toBeInTheDocument();
await act(async () => {
await runQueryButton.click();
});
expect(onRunQuery).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,124 @@
import React, { useCallback, useState } from 'react';
import { pick } from 'lodash';
import { SelectableValue } from '@grafana/data';
import { Button, ConfirmModal, RadioButtonGroup } from '@grafana/ui';
import { CloudWatchDatasource } from '../datasource';
import {
CloudWatchMetricsQuery,
CloudWatchQuery,
CloudWatchQueryMode,
MetricEditorMode,
MetricQueryType,
} from '../types';
import EditorHeader from './ui/EditorHeader';
import InlineSelect from './ui/InlineSelect';
import FlexItem from './ui/FlexItem';
import { useRegions } from '../hooks';
interface QueryHeaderProps {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
onChange: (query: CloudWatchQuery) => void;
onRunQuery: () => void;
sqlCodeEditorIsDirty: boolean;
}
const apiModes: Array<SelectableValue<CloudWatchQueryMode>> = [
{ label: 'CloudWatch Metrics', value: 'Metrics' },
{ label: 'CloudWatch Logs', value: 'Logs' },
];
const metricEditorModes: Array<SelectableValue<MetricQueryType>> = [
{ label: 'Metric Search', value: MetricQueryType.Search },
{ label: 'Metric Query', value: MetricQueryType.Query },
];
const editorModes = [
{ label: 'Builder', value: MetricEditorMode.Builder },
{ label: 'Code', value: MetricEditorMode.Code },
];
const QueryHeader: React.FC<QueryHeaderProps> = ({ query, sqlCodeEditorIsDirty, datasource, onChange, onRunQuery }) => {
const { metricEditorMode, metricQueryType, queryMode, region } = query;
const [showConfirm, setShowConfirm] = useState(false);
const [regions, regionIsLoading] = useRegions(datasource);
const onEditorModeChange = useCallback(
(newMetricEditorMode: MetricEditorMode) => {
if (
sqlCodeEditorIsDirty &&
metricQueryType === MetricQueryType.Query &&
metricEditorMode === MetricEditorMode.Code
) {
setShowConfirm(true);
return;
}
onChange({ ...query, metricEditorMode: newMetricEditorMode });
},
[setShowConfirm, onChange, sqlCodeEditorIsDirty, query, metricEditorMode, metricQueryType]
);
const onQueryModeChange = ({ value }: SelectableValue<CloudWatchQueryMode>) => {
if (value !== queryMode) {
const commonProps = pick(query, 'id', 'region', 'namespace', 'refId', 'hide', 'key', 'queryType', 'datasource');
onChange({
...commonProps,
queryMode: value,
});
}
};
return (
<EditorHeader>
<InlineSelect
label="Region"
value={regions.find((v) => v.value === region)}
placeholder="Select region"
allowCustomValue
onChange={({ value: region }) => region && onChange({ ...query, region: region })}
options={regions}
isLoading={regionIsLoading}
/>
<InlineSelect value={queryMode} options={apiModes} onChange={onQueryModeChange} />
<InlineSelect
value={metricEditorModes.find((m) => m.value === metricQueryType)}
options={metricEditorModes}
onChange={({ value }) => {
onChange({ ...query, metricQueryType: value });
}}
/>
<FlexItem grow={1} />
<RadioButtonGroup options={editorModes} size="sm" value={metricEditorMode} onChange={onEditorModeChange} />
{query.metricQueryType === MetricQueryType.Query && query.metricEditorMode === MetricEditorMode.Code && (
<Button variant="secondary" size="sm" onClick={() => onRunQuery()}>
Run query
</Button>
)}
<ConfirmModal
isOpen={showConfirm}
title="Are you sure?"
body="You will lose manual changes done to the query if you go back to the visual builder."
confirmText="Yes, I am sure."
dismissText="No, continue editing the query manually."
icon="exclamation-triangle"
onConfirm={() => {
setShowConfirm(false);
onChange({ ...query, metricEditorMode: MetricEditorMode.Builder });
}}
onDismiss={() => setShowConfirm(false)}
/>
</EditorHeader>
);
};
export default QueryHeader;

View File

@@ -0,0 +1,163 @@
import React from 'react';
import { SQLBuilderEditor } from '..';
import { act, render, screen, waitFor } from '@testing-library/react';
import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType, SQLExpression } from '../../types';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
import { QueryEditorExpressionType, QueryEditorPropertyType } from '../../expressions';
const { datasource } = setupMockedDataSource();
const makeSQLQuery = (sql?: SQLExpression): CloudWatchMetricsQuery => ({
queryMode: 'Metrics',
refId: '',
id: '',
region: 'us-east-1',
namespace: 'ec2',
dimensions: { somekey: 'somevalue' },
metricQueryType: MetricQueryType.Query,
metricEditorMode: MetricEditorMode.Builder,
sql: sql,
});
describe('Cloudwatch SQLBuilderEditor', () => {
beforeEach(() => {
datasource.getNamespaces = jest.fn().mockResolvedValue([]);
datasource.getMetrics = jest.fn().mockResolvedValue([]);
datasource.getDimensionKeys = jest.fn().mockResolvedValue([]);
datasource.getDimensionValues = jest.fn().mockResolvedValue([]);
});
const baseProps = {
query: makeSQLQuery(),
datasource,
onChange: () => {},
onRunQuery: () => {},
};
it('Displays the namespace', async () => {
const query = makeSQLQuery({
from: {
type: QueryEditorExpressionType.Property,
property: {
type: QueryEditorPropertyType.String,
name: 'AWS/EC2',
},
},
});
render(<SQLBuilderEditor {...baseProps} query={query} />);
await waitFor(() => expect(datasource.getNamespaces).toHaveBeenCalled());
expect(screen.getByText('AWS/EC2')).toBeInTheDocument();
expect(screen.getByLabelText('With schema')).not.toBeChecked();
});
it('Displays withSchema namespace', async () => {
const query = makeSQLQuery({
from: {
type: QueryEditorExpressionType.Function,
name: 'SCHEMA',
parameters: [
{
type: QueryEditorExpressionType.FunctionParameter,
name: 'AWS/EC2',
},
],
},
});
render(<SQLBuilderEditor {...baseProps} query={query} />);
await waitFor(() => expect(datasource.getNamespaces).toHaveBeenCalled());
expect(screen.getByText('AWS/EC2')).toBeInTheDocument();
expect(screen.getByLabelText('With schema')).toBeChecked();
expect(screen.getByText('Schema labels')).toBeInTheDocument();
});
it('Uses dimension filter when loading dimension keys', async () => {
const query = makeSQLQuery({
from: {
type: QueryEditorExpressionType.Function,
name: 'SCHEMA',
parameters: [
{
type: QueryEditorExpressionType.FunctionParameter,
name: 'AWS/EC2',
},
{
type: QueryEditorExpressionType.FunctionParameter,
name: 'InstanceId',
},
],
},
});
render(<SQLBuilderEditor {...baseProps} query={query} />);
act(async () => {
expect(screen.getByText('AWS/EC2')).toBeInTheDocument();
expect(screen.getByLabelText('With schema')).toBeChecked();
expect(screen.getByText('Schema labels')).toBeInTheDocument();
await waitFor(() =>
expect(datasource.getDimensionKeys).toHaveBeenCalledWith(
query.namespace,
query.region,
{ InstanceId: null },
undefined
)
);
});
});
it('Displays the SELECT correctly', async () => {
const query = makeSQLQuery({
select: {
type: QueryEditorExpressionType.Function,
name: 'AVERAGE',
parameters: [
{
type: QueryEditorExpressionType.FunctionParameter,
name: 'CPUUtilization',
},
],
},
});
render(<SQLBuilderEditor {...baseProps} query={query} />);
await waitFor(() => expect(datasource.getNamespaces).toHaveBeenCalled());
expect(screen.getByText('AVERAGE')).toBeInTheDocument();
expect(screen.getByText('CPUUtilization')).toBeInTheDocument();
});
describe('ORDER BY', async () => {
it('should display it correctly when its specified', async () => {
const query = makeSQLQuery({
orderBy: {
type: QueryEditorExpressionType.Function,
name: 'AVG',
},
});
render(<SQLBuilderEditor {...baseProps} query={query} />);
await waitFor(() => expect(datasource.getNamespaces).toHaveBeenCalled());
expect(screen.getByText('AVG')).toBeInTheDocument();
const directionElement = screen.getByLabelText('Direction');
expect(directionElement).toBeInTheDocument();
expect(directionElement).not.toBeDisabled();
});
it('should display it correctly when its not specified', async () => {
const query = makeSQLQuery({});
render(<SQLBuilderEditor {...baseProps} query={query} />);
await waitFor(() => expect(datasource.getNamespaces).toHaveBeenCalled());
expect(screen.queryByText('AVG')).toBeNull();
const directionElement = screen.getByLabelText('Direction');
expect(directionElement).toBeInTheDocument();
expect(directionElement).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,89 @@
import React, { useCallback, useEffect, useState } from 'react';
import { CloudWatchMetricsQuery } from '../../types';
import { CloudWatchDatasource } from '../../datasource';
import EditorRow from '../ui/EditorRow';
import EditorRows from '../ui/EditorRows';
import EditorField from '../ui/EditorField';
import SQLFilter from './SQLFilter';
import SQLGroupBy from './SQLGroupBy';
import SQLBuilderSelectRow from './SQLBuilderSelectRow';
import SQLGenerator from '../../cloudwatch-sql/SQLGenerator';
import SQLOrderByGroup from './SQLOrderByGroup';
import { Input } from '@grafana/ui';
import { setSql } from './utils';
export type Props = {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
onChange: (value: CloudWatchMetricsQuery) => void;
onRunQuery: () => void;
};
export function SQLBuilderEditor({ query, datasource, onChange, onRunQuery }: React.PropsWithChildren<Props>) {
const sql = query.sql ?? {};
const onQueryChange = useCallback(
(query: CloudWatchMetricsQuery) => {
const sqlGenerator = new SQLGenerator();
const sqlString = sqlGenerator.expressionToSqlQuery(query.sql ?? {});
const fullQuery = {
...query,
sqlExpression: sqlString,
};
onChange(fullQuery);
onRunQuery();
},
[onChange, onRunQuery]
);
const [sqlPreview, setSQLPreview] = useState<string | undefined>();
useEffect(() => {
const sqlGenerator = new SQLGenerator();
const sqlString = sqlGenerator.expressionToSqlQuery(query.sql ?? {});
if (sqlPreview !== sqlString) {
setSQLPreview(sqlString);
}
}, [query, sqlPreview, setSQLPreview]);
return (
<EditorRows>
<EditorRow>
<SQLBuilderSelectRow query={query} onQueryChange={onQueryChange} datasource={datasource} />
</EditorRow>
<EditorRow>
<EditorField label="Filter" optional={true}>
<SQLFilter query={query} onQueryChange={onQueryChange} datasource={datasource} />
</EditorField>
</EditorRow>
<EditorRow>
<EditorField label="Group by" optional>
<SQLGroupBy query={query} onQueryChange={onQueryChange} datasource={datasource} />
</EditorField>
<SQLOrderByGroup query={query} onQueryChange={onQueryChange} datasource={datasource}></SQLOrderByGroup>
<EditorField label="Limit" optional>
<Input
value={sql.limit}
onChange={(e) => {
const val = e.currentTarget.valueAsNumber;
onQueryChange(setSql(query, { limit: isNaN(val) ? undefined : val }));
}}
type="number"
min={1}
/>
</EditorField>
</EditorRow>
{sqlPreview && (
<EditorRow>
{process.env.NODE_ENV === 'development' && <pre>{JSON.stringify(query.sql ?? {}, null, 2)}</pre>}
<pre>{sqlPreview ?? ''}</pre>
</EditorRow>
)}
</EditorRows>
);
}

View File

@@ -0,0 +1,121 @@
import { toOption } from '@grafana/data';
import { Select, Switch } from '@grafana/ui';
import React, { useEffect, useMemo } from 'react';
import { STATISTICS } from '../../cloudwatch-sql/language';
import { CloudWatchDatasource } from '../../datasource';
import { useDimensionKeys, useMetrics, useNamespaces } from '../../hooks';
import { CloudWatchMetricsQuery } from '../../types';
import { appendTemplateVariables } from '../../utils/utils';
import EditorField from '../ui/EditorField';
import EditorFieldGroup from '../ui/EditorFieldGroup';
import {
stringArrayToDimensions,
getMetricNameFromExpression,
getNamespaceFromExpression,
getSchemaLabelKeys as getSchemaLabels,
isUsingWithSchema,
setAggregation,
setMetricName,
setNamespace,
setSchemaLabels,
setWithSchema,
} from './utils';
interface SQLBuilderSelectRowProps {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
onQueryChange: (query: CloudWatchMetricsQuery) => void;
}
const AGGREGATIONS = STATISTICS.map(toOption);
const SQLBuilderSelectRow: React.FC<SQLBuilderSelectRowProps> = ({ datasource, query, onQueryChange }) => {
const sql = query.sql ?? {};
const aggregation = sql.select?.name;
useEffect(() => {
if (!aggregation) {
onQueryChange(setAggregation(query, STATISTICS[0]));
}
}, [aggregation, onQueryChange, query]);
const metricName = getMetricNameFromExpression(sql.select);
const namespace = getNamespaceFromExpression(sql.from);
const schemaLabels = getSchemaLabels(sql.from);
const withSchemaEnabled = isUsingWithSchema(sql.from);
const namespaceOptions = useNamespaces(datasource);
const metricOptions = useMetrics(datasource, query.region, namespace);
const existingFilters = useMemo(() => stringArrayToDimensions(schemaLabels ?? []), [schemaLabels]);
const unusedDimensionKeys = useDimensionKeys(datasource, query.region, namespace, metricName, existingFilters);
const dimensionKeys = useMemo(
() => (schemaLabels?.length ? [...unusedDimensionKeys, ...schemaLabels.map(toOption)] : unusedDimensionKeys),
[unusedDimensionKeys, schemaLabels]
);
return (
<>
<EditorFieldGroup>
<EditorField label="Namespace" width={16}>
<Select
value={namespace ? toOption(namespace) : null}
inputId="cloudwatch-sql-namespace"
options={namespaceOptions}
allowCustomValue
onChange={({ value }) => value && onQueryChange(setNamespace(query, value))}
menuShouldPortal
/>
</EditorField>
<EditorField label="With schema">
<Switch
id="cloudwatch-sql-withSchema"
value={withSchemaEnabled}
onChange={(ev) =>
ev.target instanceof HTMLInputElement && onQueryChange(setWithSchema(query, ev.target.checked))
}
/>
</EditorField>
{withSchemaEnabled && (
<EditorField label="Schema labels">
<Select
id="cloudwatch-sql-schema-label-keys"
width="auto"
isMulti={true}
disabled={!namespace}
value={schemaLabels ? schemaLabels.map(toOption) : null}
options={dimensionKeys}
allowCustomValue
onChange={(item) => item && onQueryChange(setSchemaLabels(query, item))}
menuShouldPortal
/>
</EditorField>
)}
</EditorFieldGroup>
<EditorFieldGroup>
<EditorField label="Metric name" width={16}>
<Select
value={metricName ? toOption(metricName) : null}
options={metricOptions}
allowCustomValue
onChange={({ value }) => value && onQueryChange(setMetricName(query, value))}
menuShouldPortal
/>
</EditorField>
<EditorField label="Aggregation" width={16}>
<Select
value={aggregation ? toOption(aggregation) : null}
options={appendTemplateVariables(datasource, AGGREGATIONS)}
onChange={({ value }) => value && onQueryChange(setAggregation(query, value))}
menuShouldPortal
/>
</EditorField>
</EditorFieldGroup>
</>
);
};
export default SQLBuilderSelectRow;

View File

@@ -0,0 +1,160 @@
import { SelectableValue, toOption } from '@grafana/data';
import { Select } from '@grafana/ui';
import React, { useMemo, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { COMPARISON_OPERATORS, EQUALS } from '../../cloudwatch-sql/language';
import { CloudWatchDatasource } from '../../datasource';
import { QueryEditorExpressionType, QueryEditorOperatorExpression, QueryEditorPropertyType } from '../../expressions';
import { useDimensionKeys } from '../../hooks';
import { CloudWatchMetricsQuery } from '../../types';
import { appendTemplateVariables } from '../../utils/utils';
import AccessoryButton from '../ui/AccessoryButton';
import EditorList from '../ui/EditorList';
import InputGroup from '../ui/InputGroup';
import {
getFlattenedFilters,
getMetricNameFromExpression,
getNamespaceFromExpression,
sanitizeOperator,
setOperatorExpressionName,
setOperatorExpressionProperty,
setOperatorExpressionValue,
setSql,
} from './utils';
interface SQLFilterProps {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
onQueryChange: (query: CloudWatchMetricsQuery) => void;
}
const OPERATORS = COMPARISON_OPERATORS.map(toOption);
const SQLFilter: React.FC<SQLFilterProps> = ({ query, onQueryChange, datasource }) => {
const filtersFromQuery = useMemo(() => getFlattenedFilters(query.sql ?? {}), [query.sql]);
const [filters, setFilters] = useState<QueryEditorOperatorExpression[]>(filtersFromQuery);
const onChange = (newItems: Array<Partial<QueryEditorOperatorExpression>>) => {
// As new (empty object) items come in, with need to make sure they have the correct type
const cleaned = newItems.map(
(v): QueryEditorOperatorExpression => ({
type: QueryEditorExpressionType.Operator,
property: v.property ?? { type: QueryEditorPropertyType.String },
operator: v.operator ?? {
name: EQUALS,
},
})
);
setFilters(cleaned);
// Only save valid and complete filters into the query state
const validExpressions: QueryEditorOperatorExpression[] = [];
for (const operatorExpression of cleaned) {
const validated = sanitizeOperator(operatorExpression);
if (validated) {
validExpressions.push(validated);
}
}
const where = validExpressions.length
? {
type: QueryEditorExpressionType.And as const,
expressions: validExpressions,
}
: undefined;
onQueryChange(setSql(query, { where }));
};
return <EditorList items={filters} onChange={onChange} renderItem={makeRenderFilter(datasource, query)} />;
};
// Making component functions in the render body is not recommended, but it works for now.
// If some problems arise (perhaps with state going missing), consider this to be a potential cause
function makeRenderFilter(datasource: CloudWatchDatasource, query: CloudWatchMetricsQuery) {
function renderFilter(
item: Partial<QueryEditorOperatorExpression>,
onChange: (item: QueryEditorOperatorExpression) => void,
onDelete: () => void
) {
return <FilterItem datasource={datasource} query={query} filter={item} onChange={onChange} onDelete={onDelete} />;
}
return renderFilter;
}
export default SQLFilter;
interface FilterItemProps {
datasource: CloudWatchDatasource;
query: CloudWatchMetricsQuery;
filter: Partial<QueryEditorOperatorExpression>;
onChange: (item: QueryEditorOperatorExpression) => void;
onDelete: () => void;
}
const FilterItem: React.FC<FilterItemProps> = (props) => {
const { datasource, query, filter, onChange, onDelete } = props;
const sql = query.sql ?? {};
const namespace = getNamespaceFromExpression(sql.from);
const metricName = getMetricNameFromExpression(sql.select);
const dimensionKeys = useDimensionKeys(datasource, query.region, namespace, metricName);
const loadDimensionValues = async () => {
if (!filter.property?.name) {
return [];
}
return datasource
.getDimensionValues(query.region, namespace, metricName, filter.property.name, {})
.then((result: Array<SelectableValue<string>>) => {
return appendTemplateVariables(datasource, result);
});
};
const [state, loadOptions] = useAsyncFn(loadDimensionValues, [
query.region,
namespace,
metricName,
filter.property?.name,
]);
return (
<InputGroup>
<Select
width="auto"
value={filter.property?.name ? toOption(filter.property?.name) : null}
options={dimensionKeys}
allowCustomValue
onChange={({ value }) => value && onChange(setOperatorExpressionProperty(filter, value))}
menuShouldPortal
/>
<Select
width="auto"
value={filter.operator?.name && toOption(filter.operator.name)}
options={OPERATORS}
onChange={({ value }) => value && onChange(setOperatorExpressionName(filter, value))}
menuShouldPortal
/>
<Select
width="auto"
isLoading={state.loading}
value={
filter.operator?.value && typeof filter.operator?.value === 'string' ? toOption(filter.operator?.value) : null
}
options={state.value}
allowCustomValue
onOpenMenu={loadOptions}
onChange={({ value }) => value && onChange(setOperatorExpressionValue(filter, value))}
menuShouldPortal
/>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
</InputGroup>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { act, render, screen, waitFor } from '@testing-library/react';
import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType, SQLExpression } from '../../types';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
import { createArray, createGroupBy } from '../../__mocks__/sqlUtils';
import SQLGroupBy from './SQLGroupBy';
const { datasource } = setupMockedDataSource();
const makeSQLQuery = (sql?: SQLExpression): CloudWatchMetricsQuery => ({
queryMode: 'Metrics',
refId: '',
id: '',
region: 'us-east-1',
namespace: 'ec2',
dimensions: { somekey: 'somevalue' },
metricQueryType: MetricQueryType.Query,
metricEditorMode: MetricEditorMode.Builder,
sql: sql,
});
describe('Cloudwatch SQLGroupBy', () => {
const baseProps = {
query: makeSQLQuery(),
datasource,
onQueryChange: () => {},
};
it('should load dimension keys with an empty dimension filter in case no group bys exist', async () => {
const query = makeSQLQuery({
groupBy: undefined,
});
render(<SQLGroupBy {...baseProps} query={query} />);
act(async () => {
await waitFor(() =>
expect(datasource.getDimensionKeys).toHaveBeenCalledWith(query.namespace, query.region, {}, undefined)
);
});
});
it('should load dimension keys with a dimension filter in case a group bys exist', async () => {
const query = makeSQLQuery({
groupBy: createArray([createGroupBy('InstanceId'), createGroupBy('InstanceType')]),
});
render(<SQLGroupBy {...baseProps} query={query} />);
act(async () => {
expect(screen.getByText('InstanceId')).toBeInTheDocument();
expect(screen.getByText('InstanceType')).toBeInTheDocument();
await waitFor(() =>
expect(datasource.getDimensionKeys).toHaveBeenCalledWith(
query.namespace,
query.region,
{ InstanceId: null, InstanceType: null },
undefined
)
);
});
});
});

View File

@@ -0,0 +1,109 @@
import { SelectableValue, toOption } from '@grafana/data';
import { Select } from '@grafana/ui';
import React, { useMemo, useState } from 'react';
import { CloudWatchDatasource } from '../../datasource';
import { QueryEditorExpressionType, QueryEditorGroupByExpression, QueryEditorPropertyType } from '../../expressions';
import { useDimensionKeys } from '../../hooks';
import { CloudWatchMetricsQuery } from '../../types';
import AccessoryButton from '../ui/AccessoryButton';
import EditorList from '../ui/EditorList';
import InputGroup from '../ui/InputGroup';
import {
getFlattenedGroupBys,
getMetricNameFromExpression,
getNamespaceFromExpression,
setGroupByField,
setSql,
} from './utils';
interface SQLGroupByProps {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
onQueryChange: (query: CloudWatchMetricsQuery) => void;
}
const SQLGroupBy: React.FC<SQLGroupByProps> = ({ query, datasource, onQueryChange }) => {
const sql = query.sql ?? {};
const groupBysFromQuery = useMemo(() => getFlattenedGroupBys(query.sql ?? {}), [query.sql]);
const [items, setItems] = useState<QueryEditorGroupByExpression[]>(groupBysFromQuery);
const namespace = getNamespaceFromExpression(sql.from);
const metricName = getMetricNameFromExpression(sql.select);
const baseOptions = useDimensionKeys(datasource, query.region, namespace, metricName);
const options = useMemo(
// Exclude options we've already selected
() => baseOptions.filter((option) => !groupBysFromQuery.some((v) => v.property.name === option.value)),
[baseOptions, groupBysFromQuery]
);
const onChange = (newItems: Array<Partial<QueryEditorGroupByExpression>>) => {
// As new (empty object) items come in, with need to make sure they have the correct type
const cleaned = newItems.map(
(v): QueryEditorGroupByExpression => ({
type: QueryEditorExpressionType.GroupBy,
property: {
type: QueryEditorPropertyType.String,
name: v.property?.name,
},
})
);
setItems(cleaned);
// Only save complete expressions into the query state;
const completeExpressions = cleaned.filter((v) => v.property?.name);
const groupBy = completeExpressions.length
? {
type: QueryEditorExpressionType.And as const,
expressions: completeExpressions,
}
: undefined;
onQueryChange(setSql(query, { groupBy }));
};
return <EditorList items={items} onChange={onChange} renderItem={makeRenderItem(options)} />;
};
function makeRenderItem(options: Array<SelectableValue<string>>) {
function renderItem(
item: Partial<QueryEditorGroupByExpression>,
onChange: (item: QueryEditorGroupByExpression) => void,
onDelete: () => void
) {
return <GroupByItem options={options} item={item} onChange={onChange} onDelete={onDelete} />;
}
return renderItem;
}
interface GroupByItemProps {
options: Array<SelectableValue<string>>;
item: Partial<QueryEditorGroupByExpression>;
onChange: (item: QueryEditorGroupByExpression) => void;
onDelete: () => void;
}
const GroupByItem: React.FC<GroupByItemProps> = (props) => {
const { options, item, onChange, onDelete } = props;
const fieldName = item.property?.name;
return (
<InputGroup>
<Select
width="auto"
value={fieldName ? toOption(fieldName) : null}
options={options}
allowCustomValue
onChange={({ value }) => value && onChange(setGroupByField(value))}
menuShouldPortal
/>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
</InputGroup>
);
};
export default SQLGroupBy;

View File

@@ -0,0 +1,64 @@
import { SelectableValue, toOption } from '@grafana/data';
import { Select } from '@grafana/ui';
import React from 'react';
import { ASC, DESC, STATISTICS } from '../../cloudwatch-sql/language';
import { CloudWatchDatasource } from '../../datasource';
import { CloudWatchMetricsQuery } from '../../types';
import { appendTemplateVariables } from '../../utils/utils';
import AccessoryButton from '../ui/AccessoryButton';
import EditorField from '../ui/EditorField';
import EditorFieldGroup from '../ui/EditorFieldGroup';
import { setOrderBy, setSql } from './utils';
interface SQLBuilderSelectRowProps {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
onQueryChange: (query: CloudWatchMetricsQuery) => void;
}
const orderByDirections: Array<SelectableValue<string>> = [
{ label: ASC, value: ASC },
{ label: DESC, value: DESC },
];
const SQLOrderByGroup: React.FC<SQLBuilderSelectRowProps> = ({ query, onQueryChange, datasource }) => {
const sql = query.sql ?? {};
const orderBy = sql.orderBy?.name;
const orderByDirection = sql.orderByDirection;
return (
<EditorFieldGroup>
<EditorField label="Order by" optional width={16}>
<>
<Select
onChange={({ value }) => value && onQueryChange(setOrderBy(query, value))}
options={appendTemplateVariables(datasource, STATISTICS.map(toOption))}
value={orderBy ? toOption(orderBy) : null}
menuShouldPortal
/>
{orderBy && (
<AccessoryButton
aria-label="remove"
icon="times"
variant="secondary"
onClick={() => onQueryChange(setSql(query, { orderBy: undefined }))}
/>
)}
</>
</EditorField>
<EditorField label="Direction" width={16}>
<Select
inputId="cloudwatch-sql-order-by-direction"
disabled={!orderBy}
value={orderByDirection ? toOption(orderByDirection) : orderByDirections[0]}
options={appendTemplateVariables(datasource, orderByDirections)}
onChange={(item) => item && onQueryChange(setSql(query, { orderByDirection: item.value }))}
menuShouldPortal
/>
</EditorField>
</EditorFieldGroup>
);
};
export default SQLOrderByGroup;

View File

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

View File

@@ -0,0 +1,346 @@
import { SelectableValue } from './../../../../../../../packages/grafana-data/src/types/select';
import { SCHEMA } from '../../cloudwatch-sql/language';
import {
QueryEditorExpressionType,
QueryEditorPropertyType,
QueryEditorFunctionParameterExpression,
QueryEditorArrayExpression,
QueryEditorOperatorExpression,
QueryEditorGroupByExpression,
} from '../../expressions';
import { SQLExpression, CloudWatchMetricsQuery, Dimensions } from '../../types';
export function getMetricNameFromExpression(selectExpression: SQLExpression['select']): string | undefined {
return selectExpression?.parameters?.[0].name;
}
export function getNamespaceFromExpression(fromExpression: SQLExpression['from']): string | undefined {
// It's just a simple `FROM "AWS/EC2"` expression
if (fromExpression?.type === QueryEditorExpressionType.Property) {
return fromExpression.property.name; // PR TODO: do we need to test the type here? It can only be string?
}
// It's a more complicated `FROM SCHEMA("AWS/EC2", ...)` expression
if (fromExpression?.type === QueryEditorExpressionType.Function) {
// TODO: do we need to test the name of the function?
return fromExpression.parameters?.[0].name;
}
return undefined;
}
export function getSchemaLabelKeys(fromExpression: SQLExpression['from']): string[] | undefined {
// Schema label keys are second to n arguments in the from expression function
if (fromExpression?.type === QueryEditorExpressionType.Function && fromExpression?.parameters?.length) {
if (fromExpression?.parameters?.length <= 1) {
return [];
}
// ignore the first arg (the namespace)
const paramExpressions = fromExpression?.parameters.slice(1);
return paramExpressions.reduce<string[]>((acc, curr) => (curr.name ? [...acc, curr.name] : acc), []);
}
return undefined;
}
export function isUsingWithSchema(fromExpression: SQLExpression['from']): boolean {
return fromExpression?.type === QueryEditorExpressionType.Function && fromExpression.name === SCHEMA;
}
/** Given a partial operator expression, return a non-partial if it's valid, or undefined */
export function sanitizeOperator(
expression: Partial<QueryEditorOperatorExpression>
): QueryEditorOperatorExpression | undefined {
const key = expression.property?.name;
const value = expression.operator?.value;
const operator = expression.operator?.name;
if (key && value && operator) {
return {
type: QueryEditorExpressionType.Operator,
property: {
type: QueryEditorPropertyType.String,
name: key,
},
operator: {
value,
name: operator,
},
};
}
return undefined;
}
/**
* Given an array of Expressions, flattens them to the leaf Operator expressions.
* Note, this loses context of any nested ANDs or ORs, so will not be useful once we support nested conditions */
function flattenOperatorExpressions(
expressions: QueryEditorArrayExpression['expressions']
): QueryEditorOperatorExpression[] {
return expressions.flatMap((expression) => {
if (expression.type === QueryEditorExpressionType.Operator) {
return expression;
}
if (expression.type === QueryEditorExpressionType.And || expression.type === QueryEditorExpressionType.Or) {
return flattenOperatorExpressions(expression.expressions);
}
// Expressions that we don't expect to find in the WHERE filter will be ignored
return [];
});
}
/** Returns a flattened list of WHERE filters, losing all context of nested filters or AND vs OR. Not suitable
* if the UI supports nested conditions
*/
export function getFlattenedFilters(sql: SQLExpression): QueryEditorOperatorExpression[] {
const where = sql.where;
return flattenOperatorExpressions(where?.expressions ?? []);
}
/**
* Given an array of Expressions, flattens them to the leaf Operator expressions.
* Note, this loses context of any nested ANDs or ORs, so will not be useful once we support nested conditions */
function flattenGroupByExpressions(
expressions: QueryEditorArrayExpression['expressions']
): QueryEditorGroupByExpression[] {
return expressions.flatMap((expression) => {
if (expression.type === QueryEditorExpressionType.GroupBy) {
return expression;
}
// Expressions that we don't expect to find in the GROUP BY will be ignored
return [];
});
}
/** Returns a flattened list of GROUP BY expressions, losing all context of nested filters or AND vs OR.
*/
export function getFlattenedGroupBys(sql: SQLExpression): QueryEditorGroupByExpression[] {
const groupBy = sql.groupBy;
return flattenGroupByExpressions(groupBy?.expressions ?? []);
}
/** Converts a string array to a Dimensions object with null values **/
export function stringArrayToDimensions(arr: string[]): Dimensions {
return arr.reduce((acc, curr) => {
if (curr) {
return { ...acc, [curr]: null };
}
return acc;
}, {});
}
export function setSql(query: CloudWatchMetricsQuery, sql: SQLExpression): CloudWatchMetricsQuery {
return {
...query,
sql: {
...(query.sql ?? {}),
...sql,
},
};
}
export function setNamespace(query: CloudWatchMetricsQuery, namespace: string | undefined): CloudWatchMetricsQuery {
const sql = query.sql ?? {};
if (namespace === undefined) {
return setSql(query, {
from: undefined,
});
}
// It's just a simple `FROM "AWS/EC2"` expression
if (!sql.from || sql.from.type === QueryEditorExpressionType.Property) {
return setSql(query, {
from: {
type: QueryEditorExpressionType.Property,
property: {
type: QueryEditorPropertyType.String,
name: namespace,
},
},
});
}
// It's a more complicated `FROM SCHEMA("AWS/EC2", ...)` expression
if (sql.from.type === QueryEditorExpressionType.Function) {
const namespaceParam: QueryEditorFunctionParameterExpression = {
type: QueryEditorExpressionType.FunctionParameter,
name: namespace,
};
const labelKeys = (sql.from.parameters ?? []).slice(1);
return setSql(query, {
from: {
type: QueryEditorExpressionType.Function,
name: SCHEMA,
parameters: [namespaceParam, ...labelKeys],
},
});
}
// TODO: do the with schema bit
return query;
}
export function setSchemaLabels(
query: CloudWatchMetricsQuery,
schemaLabels: Array<SelectableValue<string>> | SelectableValue<string>
): CloudWatchMetricsQuery {
const sql = query.sql ?? {};
schemaLabels = Array.isArray(schemaLabels) ? schemaLabels.map((l) => l.value) : [schemaLabels.value];
// schema labels are the second parameter in the schema function. `... FROM SCHEMA("AWS/EC2", label1, label2 ...)`
if (sql.from?.type === QueryEditorExpressionType.Function && sql.from.parameters?.length) {
const parameters: QueryEditorFunctionParameterExpression[] = (schemaLabels ?? []).map((label: string) => ({
type: QueryEditorExpressionType.FunctionParameter,
name: label,
}));
const namespaceParam = (sql.from.parameters ?? [])[0];
return setSql(query, {
from: {
type: QueryEditorExpressionType.Function,
name: SCHEMA,
parameters: [namespaceParam, ...parameters],
},
});
}
return query;
}
export function setMetricName(query: CloudWatchMetricsQuery, metricName: string): CloudWatchMetricsQuery {
const param: QueryEditorFunctionParameterExpression = {
type: QueryEditorExpressionType.FunctionParameter,
name: metricName,
};
return setSql(query, {
select: {
type: QueryEditorExpressionType.Function,
...(query.sql?.select ?? {}),
parameters: [param],
},
});
}
export function setAggregation(query: CloudWatchMetricsQuery, aggregation: string): CloudWatchMetricsQuery {
return setSql(query, {
select: {
type: QueryEditorExpressionType.Function,
...(query.sql?.select ?? {}),
name: aggregation,
},
});
}
export function setOrderBy(query: CloudWatchMetricsQuery, aggregation: string): CloudWatchMetricsQuery {
return setSql(query, {
orderBy: {
type: QueryEditorExpressionType.Function,
name: aggregation,
},
});
}
export function setWithSchema(query: CloudWatchMetricsQuery, withSchema: boolean): CloudWatchMetricsQuery {
const namespace = getNamespaceFromExpression((query.sql ?? {}).from);
if (withSchema) {
const namespaceParam: QueryEditorFunctionParameterExpression = {
type: QueryEditorExpressionType.FunctionParameter,
name: namespace,
};
return setSql(query, {
from: {
type: QueryEditorExpressionType.Function,
name: SCHEMA,
parameters: [namespaceParam],
},
});
}
return setSql(query, {
from: {
type: QueryEditorExpressionType.Property,
property: {
type: QueryEditorPropertyType.String,
name: namespace,
},
},
});
}
/** Sets the left hand side (InstanceId) in an OperatorExpression
* Accepts a partial expression to use in an editor
*/
export function setOperatorExpressionProperty(
expression: Partial<QueryEditorOperatorExpression>,
property: string
): QueryEditorOperatorExpression {
return {
type: QueryEditorExpressionType.Operator,
property: {
type: QueryEditorPropertyType.String,
name: property,
},
operator: expression.operator ?? {},
};
}
/** Sets the operator ("==") in an OperatorExpression
* Accepts a partial expression to use in an editor
*/
export function setOperatorExpressionName(
expression: Partial<QueryEditorOperatorExpression>,
name: string
): QueryEditorOperatorExpression {
return {
type: QueryEditorExpressionType.Operator,
property: expression.property ?? {
type: QueryEditorPropertyType.String,
},
operator: {
...expression.operator,
name,
},
};
}
/** Sets the right hand side ("i-abc123445") in an OperatorExpression
* Accepts a partial expression to use in an editor
*/
export function setOperatorExpressionValue(
expression: Partial<QueryEditorOperatorExpression>,
value: string
): QueryEditorOperatorExpression {
return {
type: QueryEditorExpressionType.Operator,
property: expression.property ?? {
type: QueryEditorPropertyType.String,
},
operator: {
...expression.operator,
value,
},
};
}
/** Creates a GroupByExpression for a specified field
*/
export function setGroupByField(field: string): QueryEditorGroupByExpression {
return {
type: QueryEditorExpressionType.GroupBy,
property: {
type: QueryEditorPropertyType.String,
name: field,
},
};
}

View File

@@ -0,0 +1,50 @@
import React, { FunctionComponent, useCallback, useEffect } from 'react';
import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api';
import { CodeEditor, Monaco } from '@grafana/ui';
import { CloudWatchDatasource } from '../datasource';
import language from '../cloudwatch-sql/definition';
import { TRIGGER_SUGGEST } from '../cloudwatch-sql/completion/commands';
import { registerLanguage } from '../cloudwatch-sql/register';
export interface Props {
region: string;
sql: string;
onChange: (sql: string) => void;
onRunQuery: () => void;
datasource: CloudWatchDatasource;
}
export const SQLCodeEditor: FunctionComponent<Props> = ({ region, sql, onChange, onRunQuery, datasource }) => {
useEffect(() => {
datasource.sqlCompletionItemProvider.setRegion(region);
}, [region, datasource]);
const onEditorMount = useCallback(
(editor: monacoType.editor.IStandaloneCodeEditor, monaco: Monaco) => {
editor.onDidFocusEditorText(() => editor.trigger(TRIGGER_SUGGEST.id, TRIGGER_SUGGEST.id, {}));
editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => {
const text = editor.getValue();
onChange(text);
onRunQuery();
});
},
[onChange, onRunQuery]
);
return (
<CodeEditor
height={'150px'}
language={language.id}
value={sql}
onBlur={(value) => {
if (value !== sql) {
onChange(value);
}
}}
showMiniMap={false}
showLineNumbers={true}
onBeforeEditorMount={(monaco: Monaco) => registerLanguage(monaco, datasource.sqlCompletionItemProvider)}
onEditorDidMount={onEditorMount}
/>
);
};

View File

@@ -1,18 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Alias should render component 1`] = `
<div
style={
Object {
"flexGrow": 1,
}
}
>
<input
className="gf-form-input gf-form-input width-16"
onChange={[Function]}
type="text"
value="legend"
/>
</div>
`;

View File

@@ -1,6 +1,9 @@
export { Dimensions } from './Dimensions';
export { Dimensions } from './MetricStatEditor/Dimensions';
export { QueryInlineField, QueryField } from './Forms';
export { Alias } from './Alias';
export { MetricsQueryFieldsEditor } from './MetricsQueryFieldsEditor';
export { PanelQueryEditor } from './PanelQueryEditor';
export { CloudWatchLogsQueryEditor } from './LogsQueryEditor';
export { MetricStatEditor } from './MetricStatEditor';
export { SQLBuilderEditor } from './SQLBuilderEditor';
export { MathExpressionQueryField } from './MathExpressionQueryField';
export { SQLCodeEditor } from './SQLCodeEditor';