Cloudwatch: Refactor metrics query editor (#48537)

* refactor metrics query editor to return a function component

* add tests for metrics query editor

* add simple render tests for panel query editor

* remove obsolete test

* pr feedback
This commit is contained in:
Erik Sundell 2022-05-02 10:09:24 +02:00 committed by GitHub
parent 39ee365b82
commit a5c570a5f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 384 additions and 232 deletions

View File

@ -49,7 +49,9 @@ export function setupMockedDataSource({
} as any
);
datasource.getVariables = () => ['test'];
datasource.getRegions = () => Promise.resolve([]);
datasource.getNamespaces = jest.fn().mockResolvedValue([]);
datasource.getRegions = jest.fn().mockResolvedValue([]);
const fetchMock = jest.fn().mockReturnValue(of({ data }));
setBackendSrv({ fetch: fetchMock } as any);

View File

@ -10,7 +10,7 @@ import { CustomVariableModel, initialVariableModelState } from '../../../../feat
import { CloudWatchDatasource } from '../datasource';
import { CloudWatchJsonData, MetricEditorMode, MetricQueryType } from '../types';
import { MetricsQueryEditor, normalizeQuery, Props } from './MetricsQueryEditor';
import { MetricsQueryEditor, Props } from './MetricsQueryEditor';
const setup = () => {
const instanceSettings = {
@ -79,64 +79,6 @@ describe('QueryEditor', () => {
});
});
it('normalizes query on mount', async () => {
const { act } = renderer;
const props = setup();
// This does not actually even conform to the prop type but this happens on initialisation somehow
props.query = {
queryMode: 'Metrics',
apiMode: 'Metrics',
refId: '',
expression: '',
matchExact: true,
metricQueryType: MetricQueryType.Search,
metricEditorMode: MetricEditorMode.Builder,
} as any;
await act(async () => {
renderer.create(<MetricsQueryEditor {...props} />);
});
expect((props.onChange as jest.Mock).mock.calls[0][0]).toEqual({
namespace: '',
metricName: '',
expression: '',
sqlExpression: '',
dimensions: {},
region: 'default',
id: '',
alias: '',
statistic: 'Average',
period: '',
queryMode: 'Metrics',
apiMode: 'Metrics',
refId: '',
matchExact: true,
metricQueryType: MetricQueryType.Search,
metricEditorMode: MetricEditorMode.Builder,
});
});
describe('should use correct default values', () => {
it('should normalize query with default values', () => {
expect(normalizeQuery({ refId: '42' } as any)).toEqual({
namespace: '',
metricName: '',
expression: '',
sqlExpression: '',
dimensions: {},
region: 'default',
id: '',
alias: '',
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 () => {

View File

@ -1,4 +1,4 @@
import React, { ChangeEvent, PureComponent } from 'react';
import React, { ChangeEvent, useState } from 'react';
import { QueryEditorProps } from '@grafana/data';
import { EditorField, EditorRow, Space } from '@grafana/experimental';
@ -16,181 +16,132 @@ import {
} from '../types';
import QueryHeader from './QueryHeader';
import usePreparedMetricsQuery from './usePreparedMetricsQuery';
import { Alias, MathExpressionQueryField, MetricStatEditor, SQLBuilderEditor, SQLCodeEditor } from './';
export type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>;
interface State {
sqlCodeEditorIsDirty: boolean;
export interface Props extends QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData> {
query: CloudWatchMetricsQuery;
}
export const normalizeQuery = ({
namespace,
metricName,
expression,
dimensions,
region,
id,
alias,
statistic,
period,
sqlExpression,
metricQueryType,
metricEditorMode,
...rest
}: CloudWatchMetricsQuery): CloudWatchMetricsQuery => {
const normalizedQuery = {
queryMode: 'Metrics' as const,
namespace: namespace ?? '',
metricName: metricName ?? '',
expression: expression ?? '',
dimensions: dimensions ?? {},
region: region ?? 'default',
id: id ?? '',
alias: alias ?? '',
statistic: statistic ?? 'Average',
period: period ?? '',
metricQueryType: metricQueryType ?? MetricQueryType.Search,
metricEditorMode: metricEditorMode ?? MetricEditorMode.Builder,
sqlExpression: sqlExpression ?? '',
...rest,
};
return !rest.hasOwnProperty('matchExact') ? { ...normalizedQuery, matchExact: true } : normalizedQuery;
};
export const MetricsQueryEditor = (props: Props) => {
const { query, onRunQuery, datasource } = props;
const [sqlCodeEditorIsDirty, setSQLCodeEditorIsDirty] = useState(false);
const preparedQuery = usePreparedMetricsQuery(query, props.onChange);
export class MetricsQueryEditor extends PureComponent<Props, State> {
state = {
sqlCodeEditorIsDirty: false,
};
componentDidMount = () => {
const metricsQuery = this.props.query as CloudWatchMetricsQuery;
const query = normalizeQuery(metricsQuery);
this.props.onChange(query);
};
onChange = (query: CloudWatchQuery) => {
const { onChange, onRunQuery } = this.props;
const onChange = (query: CloudWatchQuery) => {
const { onChange, onRunQuery } = props;
onChange(query);
onRunQuery();
};
render() {
const { onRunQuery, datasource } = this.props;
const metricsQuery = this.props.query as CloudWatchMetricsQuery;
const query = normalizeQuery(metricsQuery);
return (
<>
<QueryHeader
query={query}
onRunQuery={onRunQuery}
datasource={datasource}
onChange={(newQuery) => {
if (isCloudWatchMetricsQuery(newQuery) && newQuery.metricEditorMode !== query.metricEditorMode) {
setSQLCodeEditorIsDirty(false);
}
onChange(newQuery);
}}
sqlCodeEditorIsDirty={sqlCodeEditorIsDirty}
/>
<Space v={0.5} />
return (
<>
<QueryHeader
query={query}
onRunQuery={onRunQuery}
datasource={datasource}
onChange={(newQuery) => {
if (isCloudWatchMetricsQuery(newQuery) && newQuery.metricEditorMode !== query.metricEditorMode) {
this.setState({ sqlCodeEditorIsDirty: false });
{query.metricQueryType === MetricQueryType.Search && (
<>
{query.metricEditorMode === MetricEditorMode.Builder && (
<MetricStatEditor
{...props}
refId={query.refId}
metricStat={query}
onChange={(metricStat: MetricStat) => props.onChange({ ...query, ...metricStat })}
></MetricStatEditor>
)}
{query.metricEditorMode === MetricEditorMode.Code && (
<MathExpressionQueryField
onRunQuery={onRunQuery}
expression={query.expression ?? ''}
onChange={(expression) => props.onChange({ ...query, expression })}
datasource={datasource}
></MathExpressionQueryField>
)}
</>
)}
{query.metricQueryType === MetricQueryType.Query && (
<>
{query.metricEditorMode === MetricEditorMode.Code && (
<SQLCodeEditor
region={query.region}
sql={query.sqlExpression ?? ''}
onChange={(sqlExpression) => {
if (!sqlCodeEditorIsDirty) {
setSQLCodeEditorIsDirty(true);
}
props.onChange({ ...preparedQuery, sqlExpression });
}}
onRunQuery={onRunQuery}
datasource={datasource}
/>
)}
{query.metricEditorMode === MetricEditorMode.Builder && (
<>
<SQLBuilderEditor
query={query}
onChange={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."
invalid={!!query.id && !/^$|^[a-z][a-zA-Z0-9_]*$/.test(query.id)}
>
<Input
id={`${query.refId}-cloudwatch-metric-query-editor-id`}
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange({ ...preparedQuery, id: event.target.value })}
type="text"
value={query.id}
/>
</EditorField>
<EditorField label="Period" width={26} tooltip="Minimum interval between points in seconds.">
<Input
id={`${query.refId}-cloudwatch-metric-query-editor-period`}
value={query.period || ''}
placeholder="auto"
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange({ ...preparedQuery, period: event.target.value })
}
this.onChange(newQuery);
}}
sqlCodeEditorIsDirty={this.state.sqlCodeEditorIsDirty}
/>
<Space v={0.5} />
/>
</EditorField>
{query.metricQueryType === MetricQueryType.Search && (
<>
{query.metricEditorMode === MetricEditorMode.Builder && (
<MetricStatEditor
{...this.props}
refId={query.refId}
metricStat={query}
onChange={(metricStat: MetricStat) => this.props.onChange({ ...query, ...metricStat })}
></MetricStatEditor>
)}
{query.metricEditorMode === MetricEditorMode.Code && (
<MathExpressionQueryField
onRunQuery={onRunQuery}
expression={query.expression ?? ''}
onChange={(expression) => this.props.onChange({ ...query, expression })}
datasource={datasource}
></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}
/>
)}
{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."
invalid={!!query.id && !/^$|^[a-z][a-zA-Z0-9_]*$/.test(query.id)}
>
<Input
id={`${query.refId}-cloudwatch-metric-query-editor-id`}
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...metricsQuery, id: event.target.value })
}
type="text"
value={query.id}
/>
</EditorField>
<EditorField label="Period" width={26} tooltip="Minimum interval between points in seconds.">
<Input
id={`${query.refId}-cloudwatch-metric-query-editor-period`}
value={query.period || ''}
placeholder="auto"
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
this.onChange({ ...metricsQuery, period: event.target.value })
}
/>
</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>
</>
);
}
}
<EditorField
label="Alias"
width={26}
optional
tooltip="Change time series legend name using this field. See documentation for replacement variable formats."
>
<Alias
value={preparedQuery.alias ?? ''}
onChange={(value: string) => onChange({ ...preparedQuery, alias: value })}
/>
</EditorField>
</EditorRow>
</>
);
};

View File

@ -0,0 +1,133 @@
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { QueryEditorProps } from '@grafana/data';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
import { CloudWatchDatasource } from '../datasource';
import { CloudWatchQuery, CloudWatchJsonData, MetricEditorMode, MetricQueryType } from '../types';
import { PanelQueryEditor } from './PanelQueryEditor';
// the following three fields are added to legacy queries in the dashboard migrator
const migratedFields = {
statistic: 'Average',
metricEditorMode: MetricEditorMode.Builder,
metricQueryType: MetricQueryType.Query,
};
const props: QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData> = {
datasource: setupMockedDataSource().datasource,
onRunQuery: jest.fn(),
onChange: jest.fn(),
query: {} as CloudWatchQuery,
};
describe('PanelQueryEditor should render right editor', () => {
describe('when using grafana 6.3.0 metric query', () => {
it('should render the metrics query editor', async () => {
const query = {
...migratedFields,
dimensions: {
InstanceId: 'i-123',
},
expression: '',
highResolution: false,
id: '',
metricName: 'CPUUtilization',
namespace: 'AWS/EC2',
period: '',
refId: 'A',
region: 'default',
returnData: false,
};
await act(async () => {
render(<PanelQueryEditor {...props} query={query} />);
});
expect(screen.getByText('Metric name')).toBeInTheDocument();
});
});
describe('when using grafana 7.0.0 style metrics query', () => {
it('should render the metrics query editor', async () => {
const query = {
...migratedFields,
alias: '',
apiMode: 'Logs',
dimensions: {
InstanceId: 'i-123',
},
expression: '',
id: '',
logGroupNames: [],
matchExact: true,
metricName: 'CPUUtilization',
namespace: 'AWS/EC2',
period: '',
queryMode: 'Logs',
refId: 'A',
region: 'ap-northeast-2',
statistics: 'Average',
} as any;
await act(async () => {
render(<PanelQueryEditor {...props} query={query} />);
});
expect(screen.getByText('Choose Log Groups')).toBeInTheDocument();
});
});
describe('when using grafana 7.0.0 style logs query', () => {
it('should render the metrics query editor', async () => {
const query = {
...migratedFields,
alias: '',
apiMode: 'Logs',
dimensions: {
InstanceId: 'i-123',
},
expression: '',
id: '',
logGroupNames: [],
matchExact: true,
metricName: 'CPUUtilization',
namespace: 'AWS/EC2',
period: '',
queryMode: 'Logs',
refId: 'A',
region: 'ap-northeast-2',
statistic: 'Average',
} as any;
await act(async () => {
render(<PanelQueryEditor {...props} query={query} />);
});
expect(screen.getByText('Log Groups')).toBeInTheDocument();
});
});
describe('when using grafana query from curated ec2 dashboard', () => {
it('should render the metrics query editor', async () => {
const query = {
...migratedFields,
alias: 'Inbound',
dimensions: {
InstanceId: '*',
},
expression:
"SUM(REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"NetworkIn\"', 'Sum', $period)))/$period",
id: '',
matchExact: true,
metricName: 'NetworkOut',
namespace: 'AWS/EC2',
period: '$period',
refId: 'B',
region: '$region',
statistic: 'Average',
} as any;
await act(async () => {
render(<PanelQueryEditor {...props} query={query} />);
});
expect(screen.getByText('Metric name')).toBeInTheDocument();
});
});
});

View File

@ -1,8 +1,9 @@
import React, { PureComponent } from 'react';
import { QueryEditorProps, ExploreMode } from '@grafana/data';
import { QueryEditorProps } from '@grafana/data';
import { CloudWatchDatasource } from '../datasource';
import { isCloudWatchMetricsQuery } from '../guards';
import { CloudWatchJsonData, CloudWatchQuery } from '../types';
import LogsQueryEditor from './LogsQueryEditor';
@ -13,14 +14,13 @@ export type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, Clou
export class PanelQueryEditor extends PureComponent<Props> {
render() {
const { query } = this.props;
const apiMode = query.queryMode ?? 'Metrics';
return (
<>
{apiMode === ExploreMode.Logs ? (
<LogsQueryEditor {...this.props} allowCustomValue />
{isCloudWatchMetricsQuery(query) ? (
<MetricsQueryEditor {...this.props} query={query} />
) : (
<MetricsQueryEditor {...this.props} />
<LogsQueryEditor {...this.props} allowCustomValue />
)}
</>
);

View File

@ -0,0 +1,76 @@
import { renderHook } from '@testing-library/react-hooks';
import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from '../types';
import usePreparedMetricsQuery, { DEFAULT_QUERY } from './usePreparedMetricsQuery';
interface TestScenario {
name: string;
query: any;
expectedQuery: CloudWatchMetricsQuery;
}
const baseQuery: CloudWatchMetricsQuery = {
refId: 'A',
id: '',
region: 'us-east-2',
namespace: 'AWS/EC2',
dimensions: { InstanceId: 'x-123' },
};
describe('usePrepareMetricsQuery', () => {
describe('when an incomplete query is provided', () => {
const testTable: TestScenario[] = [
{ name: 'Empty query', query: { refId: 'A' }, expectedQuery: { ...DEFAULT_QUERY, refId: 'A' } },
{
name: 'Match exact is not part of the query',
query: { ...baseQuery },
expectedQuery: { ...DEFAULT_QUERY, ...baseQuery, matchExact: true },
},
{
name: 'Match exact is part of the query',
query: { ...baseQuery, matchExact: false },
expectedQuery: { ...DEFAULT_QUERY, ...baseQuery, matchExact: false },
},
{
name: 'When editor mode and builder mode different from default is specified',
query: { ...baseQuery, metricQueryType: MetricQueryType.Query, metricEditorMode: MetricEditorMode.Code },
expectedQuery: {
...DEFAULT_QUERY,
...baseQuery,
metricQueryType: MetricQueryType.Query,
metricEditorMode: MetricEditorMode.Code,
},
},
];
describe.each(testTable)('scenario %#: $name', (scenario) => {
it('should set the default values and trigger onChangeQuery', async () => {
const onChangeQuery = jest.fn();
const { result } = renderHook(() => usePreparedMetricsQuery(scenario.query, onChangeQuery));
expect(onChangeQuery).toHaveBeenLastCalledWith(result.current);
expect(result.current).toEqual(scenario.expectedQuery);
});
});
});
describe('when a complete query is provided', () => {
it('should not change the query and should not call onChangeQuery', async () => {
const onChangeQuery = jest.fn();
const completeQuery: CloudWatchMetricsQuery = {
...baseQuery,
expression: '',
queryMode: 'Metrics',
metricName: '',
statistic: 'Sum',
period: '300',
metricQueryType: MetricQueryType.Query,
metricEditorMode: MetricEditorMode.Code,
sqlExpression: 'SELECT 1',
matchExact: false,
};
const { result } = renderHook(() => usePreparedMetricsQuery(completeQuery, onChangeQuery));
expect(onChangeQuery).not.toHaveBeenCalled();
expect(result.current).toEqual(completeQuery);
});
});
});

View File

@ -0,0 +1,48 @@
import deepEqual from 'fast-deep-equal';
import { useEffect, useMemo } from 'react';
import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from '../types';
export const DEFAULT_QUERY: Omit<CloudWatchMetricsQuery, 'refId'> = {
queryMode: 'Metrics',
namespace: '',
metricName: '',
expression: '',
dimensions: {},
region: 'default',
id: '',
statistic: 'Average',
period: '',
metricQueryType: MetricQueryType.Search,
metricEditorMode: MetricEditorMode.Builder,
sqlExpression: '',
matchExact: true,
};
const prepareQuery = (query: CloudWatchMetricsQuery) => {
const withDefaults = { ...DEFAULT_QUERY, ...query };
// If we didn't make any changes to the object, then return the original object to keep the
// identity the same, and not trigger any other useEffects or anything.
return deepEqual(withDefaults, query) ? query : withDefaults;
};
/**
* Returns queries with some defaults + migrations, and calls onChange function to notify if it changes
*/
const usePreparedMetricsQuery = (
query: CloudWatchMetricsQuery,
onChangeQuery: (newQuery: CloudWatchMetricsQuery) => void
) => {
const preparedQuery = useMemo(() => prepareQuery(query), [query]);
useEffect(() => {
if (preparedQuery !== query) {
onChangeQuery(preparedQuery);
}
}, [preparedQuery, query, onChangeQuery]);
return preparedQuery;
};
export default usePreparedMetricsQuery;

View File

@ -6,7 +6,7 @@ export const isCloudWatchLogsQuery = (cloudwatchQuery: CloudWatchQuery): cloudwa
cloudwatchQuery.queryMode === 'Logs';
export const isCloudWatchMetricsQuery = (cloudwatchQuery: CloudWatchQuery): cloudwatchQuery is CloudWatchMetricsQuery =>
cloudwatchQuery.queryMode === 'Metrics';
cloudwatchQuery.queryMode === 'Metrics' || !cloudwatchQuery.hasOwnProperty('queryMode'); // in early versions of this plugin, queryMode wasn't defined in a CloudWatchMetricsQuery
export const isCloudWatchAnnotationQuery = (
cloudwatchQuery: CloudWatchQuery