mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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!]: '' })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MetricStatEditor } from './MetricStatEditor';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
) : (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { SQLBuilderEditor } from './SQLBuilderEditor';
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user