Cloudwatch: Add run query button (#60089)

* refactor header

* split left and right header actions

* cleanup

* fix broken tests

* move MetricsQueryEditor tests to QueryEditor tests

* fix mock imports

* remove no longer used onRunQuery func

* move run queries button

* apply defaults also when changing query type

* remove not used prop
This commit is contained in:
Erik Sundell 2022-12-15 14:29:57 +01:00 committed by GitHub
parent 66c076f24e
commit 6928ad2949
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 404 additions and 640 deletions

View File

@ -5346,8 +5346,7 @@ exports[`better eslint`] = {
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
@ -5361,14 +5360,11 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx:5381": [
"public/app/plugins/datasource/cloudwatch/components/QueryEditor.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/datasource/cloudwatch/datasource.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -38,32 +38,30 @@ export const validMetricSearchBuilderQuery: CloudWatchMetricsQuery = {
};
export const validMetricQueryBuilderQuery: CloudWatchMetricsQuery = {
id: '',
queryMode: 'Metrics',
region: 'us-east-2',
namespace: 'AWS/EC2',
period: '3000',
alias: '',
metricName: 'CPUUtilization',
dimensions: { InstanceId: 'i-123' },
matchExact: true,
statistic: 'Average',
refId: '',
id: '',
region: 'us-east-1',
namespace: 'ec2',
dimensions: { somekey: 'somevalue' },
metricQueryType: MetricQueryType.Query,
metricEditorMode: MetricEditorMode.Builder,
sql: {
select: {
from: {
type: QueryEditorExpressionType.Function,
name: 'AVERAGE',
name: 'SCHEMA',
parameters: [
{
type: QueryEditorExpressionType.FunctionParameter,
name: 'CPUUtilization',
name: 'AWS/EC2',
},
{
type: QueryEditorExpressionType.FunctionParameter,
name: 'InstanceId',
},
],
},
},
refId: 'A',
metricQueryType: MetricQueryType.Query,
metricEditorMode: MetricEditorMode.Builder,
hide: false,
};
export const validMetricQueryCodeQuery: CloudWatchMetricsQuery = {

View File

@ -45,7 +45,6 @@ export const AnnotationQueryEditor = (props: Props) => {
metricStat={query}
disableExpressions={true}
onChange={(metricStat: MetricStat) => onChange({ ...query, ...metricStat })}
onRunQuery={() => {}}
></MetricStatEditor>
<Space v={0.5} />
<EditorRow>

View File

@ -35,7 +35,6 @@ const defaultProps = {
},
]),
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
const originalDebounce = lodash.debounce;
@ -178,13 +177,4 @@ describe('CrossAccountLogsQueryField', () => {
},
]);
});
it('runs the query on close of the modal', async () => {
const onRunQuery = jest.fn();
render(<CrossAccountLogsQueryField {...defaultProps} onRunQuery={onRunQuery} />);
await userEvent.click(screen.getByText('Select Log Groups'));
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Close dialogue'));
expect(onRunQuery).toBeCalledTimes(1);
});
});

View File

@ -16,7 +16,6 @@ type CrossAccountLogsQueryProps = {
accountOptions: Array<SelectableValue<string>>;
fetchLogGroups: (params: Partial<DescribeLogGroupsRequest>) => Promise<SelectableResourceValue[]>;
onChange: (selectedLogGroups: SelectableResourceValue[]) => void;
onRunQuery: () => void;
};
export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) => {
@ -30,7 +29,6 @@ export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) =>
const toggleModal = () => {
setIsModalOpen(!isModalOpen);
if (isModalOpen) {
props.onRunQuery();
} else {
setSelectedLogGroups(props.selectedLogGroups);
searchFn(searchPhrase, searchAccountId);

View File

@ -33,7 +33,6 @@ const props = {
query: q,
disableExpressions: false,
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
describe('Dimensions', () => {

View File

@ -13,12 +13,11 @@ const dynamicLabelsCompletionItemProvider = new DynamicLabelsCompletionItemProvi
export interface Props {
onChange: (query: string) => void;
onRunQuery: () => void;
label: string;
width: number;
}
export function DynamicLabelsField({ label, width, onChange, onRunQuery }: Props) {
export function DynamicLabelsField({ label, width, onChange }: Props) {
const theme = useTheme2();
const styles = getInputStyles({ theme, width });
const containerRef = useRef<HTMLDivElement>(null);
@ -28,13 +27,12 @@ export function DynamicLabelsField({ label, width, onChange, onRunQuery }: Props
editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => {
const text = editor.getValue();
onChange(text);
onRunQuery();
});
const containerDiv = containerRef.current;
containerDiv !== null && editor.layout({ width: containerDiv.clientWidth, height: containerDiv.clientHeight });
},
[onChange, onRunQuery]
[onChange]
);
return (
@ -69,7 +67,6 @@ export function DynamicLabelsField({ label, width, onChange, onRunQuery }: Props
onBlur={(value) => {
if (value !== label) {
onChange(value);
onRunQuery();
}
}}
onBeforeEditorMount={(monaco: Monaco) =>

View File

@ -22,7 +22,6 @@ const defaultProps = {
refId: '',
} as CloudWatchLogsQuery,
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
describe('LogGroupSelection', () => {
beforeEach(() => {

View File

@ -16,14 +16,13 @@ type Props = {
datasource: CloudWatchDatasource;
query: CloudWatchLogsQuery;
onChange: (value: CloudWatchQuery) => void;
onRunQuery: () => void;
};
const rowGap = css`
gap: 3px;
`;
export const LogGroupSelection = ({ datasource, query, onChange, onRunQuery }: Props) => {
export const LogGroupSelection = ({ datasource, query, onChange }: Props) => {
const accountState = useAccountOptions(datasource.api, query.region);
return (
@ -37,7 +36,6 @@ export const LogGroupSelection = ({ datasource, query, onChange, onRunQuery }: P
onChange({ ...query, logGroups: selectedLogGroups, logGroupNames: [] });
}}
accountOptions={accountState.value}
onRunQuery={onRunQuery}
selectedLogGroups={query.logGroups ?? []} /* todo handle defaults */
/>
) : (
@ -53,7 +51,6 @@ export const LogGroupSelection = ({ datasource, query, onChange, onRunQuery }: P
onChange={function (logGroupNames: string[]): void {
onChange({ ...query, logGroupNames, logGroups: [] });
}}
onRunQuery={onRunQuery}
refId={query.refId}
/>
}

View File

@ -21,7 +21,6 @@ export interface LogGroupSelectorProps {
onChange: (logGroups: string[]) => void;
datasource?: CloudWatchDatasource;
onRunQuery?: () => void;
onOpenMenu?: () => Promise<void>;
refId?: string;
width?: number | 'auto';
@ -33,7 +32,6 @@ export const LogGroupSelector: React.FC<LogGroupSelectorProps> = ({
selectedLogGroups,
onChange,
datasource,
onRunQuery,
onOpenMenu,
refId,
width,
@ -135,7 +133,6 @@ export const LogGroupSelector: React.FC<LogGroupSelectorProps> = ({
options={datasource ? appendTemplateVariables(datasource, logGroupOptions) : logGroupOptions}
value={selectedLogGroups}
onChange={(v) => onChange(v.filter(({ value }) => value).map(({ value }) => value))}
onBlur={onRunQuery}
closeMenuOnSelect={false}
isClearable
isOptionDisabled={() => selectedLogGroups.length >= MAX_LOG_GROUPS}

View File

@ -2,7 +2,6 @@
import { css } from '@emotion/css';
import React, { memo } from 'react';
// Types
import { AbsoluteTimeRange, QueryEditorProps } from '@grafana/data';
import { InlineFormLabel } from '@grafana/ui';
@ -22,7 +21,7 @@ const labelClass = css`
`;
export const CloudWatchLogsQueryEditor = memo(function CloudWatchLogsQueryEditor(props: Props) {
const { query, data, datasource, onRunQuery, onChange, exploreId } = props;
const { query, data, datasource, exploreId } = props;
let absolute: AbsoluteTimeRange;
if (data?.request?.range?.from) {
@ -40,13 +39,9 @@ export const CloudWatchLogsQueryEditor = memo(function CloudWatchLogsQueryEditor
return (
<CloudWatchLogsQueryField
{...props}
exploreId={exploreId}
datasource={datasource}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
history={[]}
data={data}
absoluteRange={absolute}
ExtraFieldElement={
<InlineFormLabel className={`gf-form-label--btn ${labelClass}`} width="auto" tooltip="Link to Graph in AWS">

View File

@ -1,7 +1,6 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import _, { DebouncedFunc } from 'lodash'; // eslint-disable-line lodash/import-scope
import React from 'react';
import { act } from 'react-dom/test-utils';
import { ExploreId } from '../../../../types';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
@ -13,28 +12,6 @@ jest
.mockImplementation((func: (...args: any) => any, wait?: number) => func as DebouncedFunc<typeof func>);
describe('CloudWatchLogsQueryField', () => {
it('runs onRunQuery on blur of Log Groups', async () => {
const onRunQuery = jest.fn();
const ds = setupMockedDataSource();
render(
<CloudWatchLogsQueryField
absoluteRange={{ from: 1, to: 10 }}
exploreId={ExploreId.left}
datasource={ds.datasource}
query={{} as any}
onRunQuery={onRunQuery}
onChange={() => {}}
/>
);
const multiSelect = screen.getByLabelText('Log Groups');
await act(async () => {
fireEvent.blur(multiSelect);
});
expect(onRunQuery).toHaveBeenCalled();
});
it('loads defaultLogGroups', async () => {
const onRunQuery = jest.fn();
const ds = setupMockedDataSource();

View File

@ -24,7 +24,6 @@ import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery } from '../typ
import { getStatsGroups } from '../utils/query/getStatsGroups';
import { LogGroupSelection } from './LogGroupSelection';
import QueryHeader from './QueryHeader';
export interface CloudWatchLogsQueryFieldProps
extends QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>,
@ -46,7 +45,7 @@ const plugins: Array<Plugin<Editor>> = [
),
];
export const CloudWatchLogsQueryField = (props: CloudWatchLogsQueryFieldProps) => {
const { query, datasource, onChange, onRunQuery, ExtraFieldElement, data } = props;
const { query, datasource, onChange, ExtraFieldElement, data } = props;
const showError = data?.error?.refId === query.refId;
const cleanText = datasource.languageProvider.cleanText;
@ -85,21 +84,13 @@ export const CloudWatchLogsQueryField = (props: CloudWatchLogsQueryFieldProps) =
return (
<>
<QueryHeader
query={query}
onRunQuery={onRunQuery}
datasource={datasource}
onChange={onChange}
sqlCodeEditorIsDirty={false}
/>
<LogGroupSelection datasource={datasource} query={query} onChange={onChange} onRunQuery={onRunQuery} />
<LogGroupSelection datasource={datasource} query={query} onChange={onChange} />
<div className="gf-form-inline gf-form-inline--nowrap flex-grow-1">
<div className="gf-form gf-form--grow flex-shrink-1">
<QueryField
additionalPlugins={plugins}
query={query.expression ?? ''}
onChange={onChangeQuery}
onRunQuery={props.onRunQuery}
onTypeahead={onTypeahead}
cleanText={cleanText}
placeholder="Enter a CloudWatch Logs Insights query (run with Shift+Enter)"

View File

@ -10,17 +10,11 @@ import { registerLanguage } from '../monarch/register';
export interface Props {
onChange: (query: string) => void;
onRunQuery: () => void;
expression: string;
datasource: CloudWatchDatasource;
}
export function MathExpressionQueryField({
expression: query,
onChange,
onRunQuery,
datasource,
}: React.PropsWithChildren<Props>) {
export function MathExpressionQueryField({ expression: query, onChange, datasource }: React.PropsWithChildren<Props>) {
const containerRef = useRef<HTMLDivElement>(null);
const onEditorMount = useCallback(
(editor: monacoType.editor.IStandaloneCodeEditor, monaco: Monaco) => {
@ -28,7 +22,6 @@ export function MathExpressionQueryField({
editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => {
const text = editor.getValue();
onChange(text);
onRunQuery();
});
// auto resizes the editor to be the height of the content it holds
@ -48,7 +41,7 @@ export function MathExpressionQueryField({
editor.onDidContentSizeChange(updateElementHeight);
updateElementHeight();
},
[onChange, onRunQuery]
[onChange]
);
return (
@ -77,7 +70,6 @@ export function MathExpressionQueryField({
onBlur={(value) => {
if (value !== query) {
onChange(value);
onRunQuery();
}
}}
onBeforeEditorMount={(monaco: Monaco) =>

View File

@ -33,7 +33,6 @@ const props = {
datasource: ds.datasource,
metricStat,
onChange: jest.fn(),
onRunQuery: jest.fn(),
};
describe('MetricStatEditor', () => {
@ -43,10 +42,9 @@ describe('MetricStatEditor', () => {
describe('statistics field', () => {
test.each([['Average', 'p23.23', 'p34', '$statistic']])('should accept valid values', async (statistic) => {
const onChange = jest.fn();
const onRunQuery = jest.fn();
props.datasource.getVariables = jest.fn().mockReturnValue(['$statistic']);
render(<MetricStatEditor {...props} onChange={onChange} onRunQuery={onRunQuery} />);
render(<MetricStatEditor {...props} onChange={onChange} />);
const statisticElement = await screen.findByLabelText('Statistic');
expect(statisticElement).toBeInTheDocument();
@ -54,14 +52,12 @@ describe('MetricStatEditor', () => {
await userEvent.type(statisticElement, statistic);
fireEvent.keyDown(statisticElement, { keyCode: 13 });
expect(onChange).toHaveBeenCalledWith({ ...props.metricStat, statistic });
expect(onRunQuery).toHaveBeenCalled();
});
test.each([['CustomStat', 'p23,23', '$statistic']])('should not accept invalid values', async (statistic) => {
const onChange = jest.fn();
const onRunQuery = jest.fn();
render(<MetricStatEditor {...props} onChange={onChange} onRunQuery={onRunQuery} />);
render(<MetricStatEditor {...props} onChange={onChange} />);
const statisticElement = await screen.findByLabelText('Statistic');
expect(statisticElement).toBeInTheDocument();
@ -69,7 +65,6 @@ describe('MetricStatEditor', () => {
await userEvent.type(statisticElement, statistic);
fireEvent.keyDown(statisticElement, { keyCode: 13 });
expect(onChange).not.toHaveBeenCalled();
expect(onRunQuery).not.toHaveBeenCalled();
});
});
@ -120,18 +115,15 @@ describe('MetricStatEditor', () => {
{ value: 'm2', label: 'm2', text: 'm2' },
];
const onChange = jest.fn();
const onRunQuery = jest.fn();
const propsNamespaceMetrics = {
...props,
onChange,
onRunQuery,
};
beforeEach(() => {
propsNamespaceMetrics.datasource.api.getNamespaces = jest.fn().mockResolvedValue(namespaces);
propsNamespaceMetrics.datasource.api.getMetrics = jest.fn().mockResolvedValue(metrics);
onChange.mockClear();
onRunQuery.mockClear();
});
it('should select namespace and metric name correctly', async () => {
@ -151,7 +143,6 @@ describe('MetricStatEditor', () => {
[{ ...propsNamespaceMetrics.metricStat, namespace: 'n1' }], // First call, namespace select
[{ ...propsNamespaceMetrics.metricStat, metricName: 'm1' }], // Second call, metric select
]);
expect(onRunQuery).toHaveBeenCalledTimes(2);
});
it('should remove metricName from metricStat if it does not exist in new namespace', async () => {

View File

@ -19,7 +19,6 @@ export type Props = {
datasource: CloudWatchDatasource;
disableExpressions?: boolean;
onChange: (value: MetricStat) => void;
onRunQuery: () => void;
};
export function MetricStatEditor({
@ -28,7 +27,6 @@ export function MetricStatEditor({
datasource,
disableExpressions = false,
onChange,
onRunQuery,
}: React.PropsWithChildren<Props>) {
const namespaces = useNamespaces(datasource);
const metrics = useMetrics(datasource, metricStat);
@ -47,14 +45,9 @@ export function MetricStatEditor({
});
}, [accountState, metricStat, onChange, datasource.api]);
const onMetricStatChange = (metricStat: MetricStat) => {
onChange(metricStat);
onRunQuery();
};
const onNamespaceChange = async (metricStat: MetricStat) => {
const validatedQuery = await validateMetricName(metricStat);
onMetricStatChange(validatedQuery);
onChange(validatedQuery);
};
const validateMetricName = async (metricStat: MetricStat) => {
@ -78,7 +71,6 @@ export function MetricStatEditor({
accountId={metricStat.accountId}
onChange={(accountId?: string) => {
onChange({ ...metricStat, accountId });
onRunQuery();
}}
accountOptions={accountState?.value || []}
></Account>
@ -105,7 +97,7 @@ export function MetricStatEditor({
options={metrics}
onChange={({ value: metricName }) => {
if (metricName) {
onMetricStatChange({ ...metricStat, metricName });
onChange({ ...metricStat, metricName });
}
}}
/>
@ -130,7 +122,7 @@ export function MetricStatEditor({
return;
}
onMetricStatChange({ ...metricStat, statistic });
onChange({ ...metricStat, statistic });
}}
/>
</EditorField>
@ -141,7 +133,7 @@ export function MetricStatEditor({
<EditorField label="Dimensions">
<Dimensions
metricStat={metricStat}
onChange={(dimensions) => onMetricStatChange({ ...metricStat, dimensions })}
onChange={(dimensions) => onChange({ ...metricStat, dimensions })}
dimensionKeys={dimensionKeys}
disableExpressions={disableExpressions}
datasource={datasource}
@ -157,7 +149,7 @@ export function MetricStatEditor({
id={`${refId}-cloudwatch-match-exact`}
value={!!metricStat.matchExact}
onChange={(e) => {
onMetricStatChange({
onChange({
...metricStat,
matchExact: e.currentTarget.checked,
});

View File

@ -69,6 +69,8 @@ const setup = () => {
metricQueryType: MetricQueryType.Search,
metricEditorMode: MetricEditorMode.Builder,
},
extraHeaderElementLeft: () => {},
extraHeaderElementRight: () => {},
datasource,
history: [],
onChange: jest.fn(),
@ -79,66 +81,6 @@ const setup = () => {
};
describe('QueryEditor', () => {
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();
if (props.query.queryMode !== 'Metrics') {
fail(`expected props.query.queryMode to be 'Metrics', got '${props.query.queryMode}' instead`);
}
props.query.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();
if (props.query.queryMode !== 'Metrics') {
fail(`expected props.query.queryMode to be 'Metrics', got '${props.query.queryMode}' instead`);
}
props.query.metricQueryType = MetricQueryType.Query;
props.query.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();
if (props.query.queryMode !== 'Metrics') {
fail(`expected props.query.queryMode to be 'Metrics', got '${props.query.queryMode}' instead`);
}
props.query.metricQueryType = MetricQueryType.Query;
props.query.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', async () => {
const props = setup();

View File

@ -1,13 +1,12 @@
import React, { ChangeEvent, useState } from 'react';
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { QueryEditorProps } from '@grafana/data';
import { EditorField, EditorRow, Space } from '@grafana/experimental';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { EditorField, EditorRow, InlineSelect, Space } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Input } from '@grafana/ui';
import { ConfirmModal, Input, RadioButtonGroup } from '@grafana/ui';
import { MathExpressionQueryField, MetricStatEditor, SQLBuilderEditor, SQLCodeEditor } from '../';
import { MathExpressionQueryField, MetricStatEditor, SQLBuilderEditor } from '../';
import { CloudWatchDatasource } from '../../datasource';
import { isCloudWatchMetricsQuery } from '../../guards';
import useMigratedMetricsQuery from '../../migrations/useMigratedMetricsQuery';
import {
CloudWatchJsonData,
@ -18,39 +17,99 @@ import {
MetricStat,
} from '../../types';
import { DynamicLabelsField } from '../DynamicLabelsField';
import QueryHeader from '../QueryHeader';
import { SQLCodeEditor } from '../SQLCodeEditor';
import { Alias } from './Alias';
export interface Props extends QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData> {
query: CloudWatchMetricsQuery;
extraHeaderElementLeft?: React.Dispatch<JSX.Element | undefined>;
extraHeaderElementRight?: React.Dispatch<JSX.Element | undefined>;
}
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 },
];
export const MetricsQueryEditor = (props: Props) => {
const { query, onRunQuery, datasource } = props;
const { query, datasource, extraHeaderElementLeft, extraHeaderElementRight, onChange } = props;
const [showConfirm, setShowConfirm] = useState(false);
const [sqlCodeEditorIsDirty, setSQLCodeEditorIsDirty] = useState(false);
const migratedQuery = useMigratedMetricsQuery(query, props.onChange);
const onChange = (query: CloudWatchQuery) => {
const { onChange, onRunQuery } = props;
onChange(query);
onRunQuery();
};
const onEditorModeChange = useCallback(
(newMetricEditorMode: MetricEditorMode) => {
if (
sqlCodeEditorIsDirty &&
query.metricQueryType === MetricQueryType.Query &&
query.metricEditorMode === MetricEditorMode.Code
) {
setShowConfirm(true);
return;
}
onChange({ ...query, metricEditorMode: newMetricEditorMode });
},
[setShowConfirm, onChange, sqlCodeEditorIsDirty, query]
);
useEffect(() => {
extraHeaderElementLeft?.(
<InlineSelect
aria-label="Metric editor mode"
value={metricEditorModes.find((m) => m.value === query.metricQueryType)}
options={metricEditorModes}
onChange={({ value }) => {
onChange({ ...query, metricQueryType: value });
}}
/>
);
extraHeaderElementRight?.(
<>
<RadioButtonGroup
options={editorModes}
size="sm"
value={query.metricEditorMode}
onChange={onEditorModeChange}
/>
<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)}
/>
</>
);
return () => {
extraHeaderElementLeft?.(undefined);
extraHeaderElementRight?.(undefined);
};
}, [
query,
sqlCodeEditorIsDirty,
datasource,
onChange,
extraHeaderElementLeft,
extraHeaderElementRight,
showConfirm,
onEditorModeChange,
]);
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} />
{query.metricQueryType === MetricQueryType.Search && (
@ -65,7 +124,6 @@ export const MetricsQueryEditor = (props: Props) => {
)}
{query.metricEditorMode === MetricEditorMode.Code && (
<MathExpressionQueryField
onRunQuery={onRunQuery}
expression={query.expression ?? ''}
onChange={(expression) => props.onChange({ ...query, expression })}
datasource={datasource}
@ -85,19 +143,13 @@ export const MetricsQueryEditor = (props: Props) => {
}
props.onChange({ ...migratedQuery, sqlExpression });
}}
onRunQuery={onRunQuery}
datasource={datasource}
/>
)}
{query.metricEditorMode === MetricEditorMode.Builder && (
<>
<SQLBuilderEditor
query={query}
onChange={props.onChange}
onRunQuery={onRunQuery}
datasource={datasource}
></SQLBuilderEditor>
<SQLBuilderEditor query={query} onChange={props.onChange} datasource={datasource}></SQLBuilderEditor>
</>
)}
</>
@ -113,7 +165,6 @@ export const MetricsQueryEditor = (props: Props) => {
>
<Input
id={`${query.refId}-cloudwatch-metric-query-editor-id`}
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange({ ...migratedQuery, id: event.target.value })}
type="text"
value={query.id}
@ -125,7 +176,6 @@ export const MetricsQueryEditor = (props: Props) => {
id={`${query.refId}-cloudwatch-metric-query-editor-period`}
value={query.period || ''}
placeholder="auto"
onBlur={onRunQuery}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
onChange({ ...migratedQuery, period: event.target.value })
}
@ -141,7 +191,6 @@ export const MetricsQueryEditor = (props: Props) => {
>
<DynamicLabelsField
width={52}
onRunQuery={onRunQuery}
label={migratedQuery.label ?? ''}
onChange={(label) => props.onChange({ ...query, label })}
></DynamicLabelsField>

View File

@ -1,139 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from '../../types';
import MetricsQueryHeader from './MetricsQueryHeader';
const ds = setupMockedDataSource({
variables: [],
});
ds.datasource.api.getRegions = jest.fn().mockResolvedValue([]);
const query: CloudWatchMetricsQuery = {
id: '',
region: 'us-east-2',
namespace: '',
period: '',
alias: '',
metricName: '',
dimensions: {},
matchExact: true,
statistic: '',
expression: '',
refId: '',
};
describe('MetricsQueryHeader', () => {
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(
<MetricsQueryHeader
sqlCodeEditorIsDirty={true}
datasource={ds.datasource}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
isMonitoringAccount={false}
/>
);
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(
<MetricsQueryHeader
sqlCodeEditorIsDirty={true}
datasource={ds.datasource}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
isMonitoringAccount={false}
/>
);
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(
<MetricsQueryHeader
sqlCodeEditorIsDirty={true}
datasource={ds.datasource}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
isMonitoringAccount={false}
/>
);
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('should call run query when run button is clicked when in metric query mode', async () => {
const onChange = jest.fn();
const onRunQuery = jest.fn();
query.metricEditorMode = MetricEditorMode.Code;
query.metricQueryType = MetricQueryType.Query;
render(
<MetricsQueryHeader
sqlCodeEditorIsDirty={true}
datasource={ds.datasource}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
isMonitoringAccount={false}
/>
);
const runQueryButton = screen.getByText('Run query');
expect(runQueryButton).toBeInTheDocument();
await act(async () => {
await runQueryButton.click();
});
expect(onRunQuery).toHaveBeenCalled();
});
});

View File

@ -1,105 +0,0 @@
import React, { useCallback, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { FlexItem, InlineSelect } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Badge, Button, ConfirmModal, RadioButtonGroup } from '@grafana/ui';
import { CloudWatchDatasource } from '../../datasource';
import { CloudWatchMetricsQuery, CloudWatchQuery, MetricEditorMode, MetricQueryType } from '../../types';
interface MetricsQueryHeaderProps {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
onChange: (query: CloudWatchQuery) => void;
onRunQuery: () => void;
sqlCodeEditorIsDirty: boolean;
isMonitoringAccount: boolean;
}
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 MetricsQueryHeader: React.FC<MetricsQueryHeaderProps> = ({
query,
sqlCodeEditorIsDirty,
onChange,
onRunQuery,
isMonitoringAccount,
}) => {
const { metricEditorMode, metricQueryType } = query;
const [showConfirm, setShowConfirm] = useState(false);
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 shouldDisplayMonitoringBadge =
query.metricQueryType === MetricQueryType.Search &&
isMonitoringAccount &&
config.featureToggles.cloudWatchCrossAccountQuerying;
return (
<>
<InlineSelect
aria-label="Metric editor mode"
value={metricEditorModes.find((m) => m.value === metricQueryType)}
options={metricEditorModes}
onChange={({ value }) => {
onChange({ ...query, metricQueryType: value });
}}
/>
<FlexItem grow={1} />
{shouldDisplayMonitoringBadge && (
<Badge
text="Monitoring account"
color="blue"
tooltip="AWS monitoring accounts view data from source accounts so you can centralize monitoring and troubleshoot activites"
></Badge>
)}
<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)}
/>
</>
);
};
export default MetricsQueryHeader;

View File

@ -1,25 +0,0 @@
import React, { PureComponent } from 'react';
import { QueryEditorProps } from '@grafana/data';
import { CloudWatchDatasource } from '../datasource';
import { isCloudWatchLogsQuery, isCloudWatchMetricsQuery } from '../guards';
import { CloudWatchJsonData, CloudWatchQuery } from '../types';
import { MetricsQueryEditor } from '././MetricsQueryEditor/MetricsQueryEditor';
import LogsQueryEditor from './LogsQueryEditor';
export type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>;
export class PanelQueryEditor extends PureComponent<Props> {
render() {
const { query } = this.props;
return (
<>
{isCloudWatchMetricsQuery(query) && <MetricsQueryEditor {...this.props} query={query} />}
{isCloudWatchLogsQuery(query) && <LogsQueryEditor {...this.props} query={query} />}
</>
);
}
}

View File

@ -1,4 +1,4 @@
import { act, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { QueryEditorProps } from '@grafana/data';
@ -15,7 +15,7 @@ import {
import { CloudWatchDatasource } from '../datasource';
import { CloudWatchQuery, CloudWatchJsonData, MetricEditorMode, MetricQueryType } from '../types';
import { PanelQueryEditor } from './PanelQueryEditor';
import { QueryEditor } from './QueryEditor';
// the following three fields are added to legacy queries in the dashboard migrator
const migratedFields = {
@ -31,7 +31,22 @@ const props: QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJ
query: {} as CloudWatchQuery,
};
describe('PanelQueryEditor should render right editor', () => {
const FAKE_EDITOR_LABEL = 'FakeEditor';
jest.mock('./SQLCodeEditor', () => ({
SQLCodeEditor: ({ sql, onChange }: { sql: string; onChange: (val: string) => void }) => {
return (
<>
<label htmlFor="cloudwatch-fake-editor">{FAKE_EDITOR_LABEL}</label>
<input id="cloudwatch-fake-editor" value={sql} onChange={(e) => onChange(e.currentTarget.value)}></input>
</>
);
},
}));
export { SQLCodeEditor } from './SQLCodeEditor';
describe('QueryEditor should render right editor', () => {
describe('when using grafana 6.3.0 metric query', () => {
it('should render the metrics query editor', async () => {
const query = {
@ -50,7 +65,7 @@ describe('PanelQueryEditor should render right editor', () => {
returnData: false,
};
await act(async () => {
render(<PanelQueryEditor {...props} query={query} />);
render(<QueryEditor {...props} query={query} />);
});
expect(screen.getByText('Metric name')).toBeInTheDocument();
});
@ -78,7 +93,7 @@ describe('PanelQueryEditor should render right editor', () => {
statistics: 'Average',
} as any;
await act(async () => {
render(<PanelQueryEditor {...props} query={query} />);
render(<QueryEditor {...props} query={query} />);
});
expect(screen.getByText('Choose Log Groups')).toBeInTheDocument();
});
@ -106,7 +121,7 @@ describe('PanelQueryEditor should render right editor', () => {
statistic: 'Average',
} as any;
await act(async () => {
render(<PanelQueryEditor {...props} query={query} />);
render(<QueryEditor {...props} query={query} />);
});
expect(screen.getByText('Log Groups')).toBeInTheDocument();
});
@ -133,7 +148,7 @@ describe('PanelQueryEditor should render right editor', () => {
statistic: 'Average',
} as any;
await act(async () => {
render(<PanelQueryEditor {...props} query={query} />);
render(<QueryEditor {...props} query={query} />);
});
expect(screen.getByText('Metric name')).toBeInTheDocument();
});
@ -177,7 +192,7 @@ describe('PanelQueryEditor should render right editor', () => {
test.each(cases)('$name', async ({ query, toggle }) => {
config.featureToggles.cloudWatchCrossAccountQuerying = toggle;
await act(async () => {
render(<PanelQueryEditor {...props} datasource={datasourceMock.datasource} query={query} />);
render(<QueryEditor {...props} datasource={datasourceMock.datasource} query={query} />);
});
expect(await screen.getByText('Monitoring account')).toBeInTheDocument();
});
@ -193,7 +208,7 @@ describe('PanelQueryEditor should render right editor', () => {
{
name: 'it is metric query code query and toggle is not enabled',
query: validMetricQueryCodeQuery,
toggle: true,
toggle: false,
},
{ name: 'it is logs query and feature is not enabled', query: validLogsQuery, toggle: false },
{
@ -210,10 +225,128 @@ describe('PanelQueryEditor should render right editor', () => {
test.each(cases)('$name', async ({ query, toggle }) => {
config.featureToggles.cloudWatchCrossAccountQuerying = toggle;
await act(async () => {
render(<PanelQueryEditor {...props} datasource={datasourceMock.datasource} query={query} />);
render(<QueryEditor {...props} datasource={datasourceMock.datasource} query={query} />);
});
expect(await screen.queryByText('Monitoring account')).toBeNull();
});
});
});
describe('QueryHeader', () => {
it('should display metric actions in header when metric query is used', async () => {
await act(async () => {
render(<QueryEditor {...props} query={validMetricQueryCodeQuery} />);
});
expect(screen.getByLabelText(/Region.*/)).toBeInTheDocument();
expect(screen.getByText('CloudWatch Metrics')).toBeInTheDocument();
expect(screen.getByLabelText('Builder')).toBeInTheDocument();
expect(screen.getByLabelText('Code')).toBeInTheDocument();
expect(screen.getByText('Metric Query')).toBeInTheDocument();
});
it('should display metric actions in header when metric query is used', async () => {
await act(async () => {
render(<QueryEditor {...props} query={validLogsQuery} />);
});
expect(screen.getByLabelText(/Region.*/)).toBeInTheDocument();
expect(screen.getByText('CloudWatch Logs')).toBeInTheDocument();
expect(screen.queryByLabelText('Builder')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Code')).not.toBeInTheDocument();
expect(screen.queryByText('Metric Query')).not.toBeInTheDocument();
});
});
describe('metrics editor should handle editor modes correctly', () => {
it('when metric query type is metric search and editor mode is builder', async () => {
await act(async () => {
render(<QueryEditor {...props} query={validMetricSearchBuilderQuery} />);
});
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 () => {
render(<QueryEditor {...props} query={validMetricSearchCodeQuery} />);
});
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 () => {
await act(async () => {
render(<QueryEditor {...props} query={validMetricQueryBuilderQuery} />);
});
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 () => {
render(<QueryEditor {...props} query={validMetricQueryCodeQuery} />);
});
expect(screen.getByText('Metric Query')).toBeInTheDocument();
const radio = screen.getByLabelText('Code');
expect(radio instanceof HTMLInputElement && radio.checked).toBeTruthy();
});
});
describe('confirm modal', () => {
it('should be shown when moving from code editor to builder when in sql mode', async () => {
const sqlQuery = 'SELECT * FROM test';
render(
<QueryEditor
{...props}
query={{ ...validMetricQueryCodeQuery, sqlExpression: sqlQuery }}
onChange={jest.fn()}
onRunQuery={jest.fn()}
/>
);
// the modal should not be shown unless the code editor is "dirty", so need to trigger a change
const codeEditorElement = screen.getByLabelText(FAKE_EDITOR_LABEL);
fireEvent.change(codeEditorElement, { target: { value: 'select * from ' } });
const builderElement = screen.getByLabelText('Builder');
expect(builderElement).toBeInTheDocument();
await act(async () => {
await builderElement.click();
});
const modalTitleElem = screen.getByText('Are you sure?');
expect(modalTitleElem).toBeInTheDocument();
});
it('should not be shown when moving from builder to code when in sql mode', async () => {
render(
<QueryEditor {...props} query={validMetricQueryBuilderQuery} onChange={jest.fn()} onRunQuery={jest.fn()} />
);
const builderElement = screen.getByLabelText('Builder');
expect(builderElement).toBeInTheDocument();
await act(async () => {
await builderElement.click();
});
expect(screen.queryByText('Are you sure?')).toBeNull();
});
it('should not be shown when moving from code to builder when in search mode', async () => {
render(<QueryEditor {...props} query={validMetricSearchCodeQuery} onChange={jest.fn()} onRunQuery={jest.fn()} />);
const builderElement = screen.getByLabelText('Builder');
expect(builderElement).toBeInTheDocument();
await act(async () => {
await builderElement.click();
});
expect(screen.queryByText('Are you sure?')).toBeNull();
});
});
});

View File

@ -0,0 +1,55 @@
import React, { useCallback, useEffect, useState } from 'react';
import { QueryEditorProps } from '@grafana/data';
import { CloudWatchDatasource } from '../datasource';
import { isCloudWatchLogsQuery, isCloudWatchMetricsQuery } from '../guards';
import { CloudWatchJsonData, CloudWatchQuery } from '../types';
import LogsQueryEditor from './LogsQueryEditor';
import { MetricsQueryEditor } from './MetricsQueryEditor/MetricsQueryEditor';
import QueryHeader from './QueryHeader';
export type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>;
export const QueryEditor = (props: Props) => {
const { query, onChange, data } = props;
const [dataIsStale, setDataIsStale] = useState(false);
const [extraHeaderElementLeft, setExtraHeaderElementLeft] = useState<JSX.Element>();
const [extraHeaderElementRight, setExtraHeaderElementRight] = useState<JSX.Element>();
useEffect(() => {
setDataIsStale(false);
}, [data]);
const onChangeInternal = useCallback(
(query: CloudWatchQuery) => {
setDataIsStale(true);
onChange(query);
},
[onChange]
);
return (
<>
<QueryHeader
{...props}
extraHeaderElementLeft={extraHeaderElementLeft}
extraHeaderElementRight={extraHeaderElementRight}
dataIsStale={dataIsStale}
/>
{isCloudWatchMetricsQuery(query) && (
<MetricsQueryEditor
{...props}
query={query}
onRunQuery={() => {}}
onChange={onChangeInternal}
extraHeaderElementLeft={setExtraHeaderElementLeft}
extraHeaderElementRight={setExtraHeaderElementRight}
/>
)}
{isCloudWatchLogsQuery(query) && <LogsQueryEditor {...props} query={query} onChange={onChangeInternal} />}
</>
);
};

View File

@ -6,7 +6,6 @@ import { config } from '@grafana/runtime';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
import { validLogsQuery, validMetricSearchBuilderQuery } from '../__mocks__/queries';
import { CloudWatchLogsQuery, CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from '../types';
import QueryHeader from './QueryHeader';
@ -20,74 +19,6 @@ describe('QueryHeader', () => {
afterEach(() => {
config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
});
it('should display metric options for metrics', async () => {
const query: CloudWatchMetricsQuery = {
queryMode: 'Metrics',
id: '',
region: 'us-east-2',
namespace: '',
period: '',
alias: '',
metricName: '',
dimensions: {},
matchExact: true,
statistic: '',
expression: '',
refId: '',
};
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 display metric options for logs', async () => {
const onChange = jest.fn();
const onRunQuery = jest.fn();
const query: CloudWatchLogsQuery = {
queryType: 'Metrics',
id: '',
region: 'us-east-2',
expression: '',
refId: '',
queryMode: 'Logs',
};
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={ds.datasource}
query={query}
onChange={onChange}
onRunQuery={onRunQuery}
/>
);
await waitFor(() => {
expect(screen.queryByLabelText('Builder')).toBeNull();
expect(screen.queryByLabelText('Code')).toBeNull();
});
});
describe('when changing region', () => {
const { datasource } = setupMockedDataSource();
@ -101,11 +32,11 @@ describe('QueryHeader', () => {
datasource.api.isMonitoringAccount = jest.fn().mockResolvedValue(false);
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={datasource}
query={{ ...validMetricSearchBuilderQuery, region: 'us-east-1', accountId: 'all' }}
onChange={onChange}
onRunQuery={jest.fn()}
dataIsStale={false}
/>
);
await waitFor(() => expect(screen.queryByText('us-east-1')).toBeInTheDocument());
@ -126,11 +57,11 @@ describe('QueryHeader', () => {
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
datasource={datasource}
query={{ ...validMetricSearchBuilderQuery, region: 'us-east-1', accountId: '123' }}
onChange={onChange}
onRunQuery={jest.fn()}
dataIsStale={false}
/>
);
await waitFor(() => expect(screen.queryByText('us-east-1')).toBeInTheDocument());
@ -151,7 +82,7 @@ describe('QueryHeader', () => {
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
dataIsStale={false}
datasource={datasource}
query={{ ...validLogsQuery, region: 'us-east-1' }}
onChange={onChange}
@ -172,7 +103,7 @@ describe('QueryHeader', () => {
render(
<QueryHeader
sqlCodeEditorIsDirty={true}
dataIsStale={false}
datasource={datasource}
query={{ ...validLogsQuery, region: 'us-east-1' }}
onChange={onChange}

View File

@ -1,23 +1,19 @@
import React from 'react';
import { SelectableValue, ExploreMode } from '@grafana/data';
import { CoreApp, LoadingState, QueryEditorProps, SelectableValue } from '@grafana/data';
import { EditorHeader, InlineSelect, FlexItem } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Badge } from '@grafana/ui';
import { Badge, Button } from '@grafana/ui';
import { CloudWatchDatasource } from '../datasource';
import { isCloudWatchMetricsQuery } from '../guards';
import { useIsMonitoringAccount, useRegions } from '../hooks';
import { CloudWatchQuery, CloudWatchQueryMode } from '../types';
import { CloudWatchJsonData, CloudWatchQuery, CloudWatchQueryMode, MetricQueryType } from '../types';
import MetricsQueryHeader from './MetricsQueryEditor/MetricsQueryHeader';
interface QueryHeaderProps {
query: CloudWatchQuery;
datasource: CloudWatchDatasource;
onChange: (query: CloudWatchQuery) => void;
onRunQuery: () => void;
sqlCodeEditorIsDirty: boolean;
export interface Props extends QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData> {
extraHeaderElementLeft?: JSX.Element;
extraHeaderElementRight?: JSX.Element;
dataIsStale: boolean;
}
const apiModes: Array<SelectableValue<CloudWatchQueryMode>> = [
@ -25,18 +21,27 @@ const apiModes: Array<SelectableValue<CloudWatchQueryMode>> = [
{ label: 'CloudWatch Logs', value: 'Logs' },
];
const QueryHeader: React.FC<QueryHeaderProps> = ({ query, sqlCodeEditorIsDirty, datasource, onChange, onRunQuery }) => {
const QueryHeader: React.FC<Props> = ({
query,
onChange,
datasource,
extraHeaderElementLeft,
extraHeaderElementRight,
dataIsStale,
data,
onRunQuery,
}) => {
const { queryMode, region } = query;
const isMonitoringAccount = useIsMonitoringAccount(datasource.api, query.region);
const [regions, regionIsLoading] = useRegions(datasource);
const onQueryModeChange = ({ value }: SelectableValue<CloudWatchQueryMode>) => {
if (value !== queryMode) {
if (value && value !== queryMode) {
onChange({
...datasource.getDefaultQuery(CoreApp.Unknown),
...query,
queryMode: value,
} as CloudWatchQuery);
});
}
};
const onRegionChange = async (region: string) => {
@ -49,44 +54,60 @@ const QueryHeader: React.FC<QueryHeaderProps> = ({ query, sqlCodeEditorIsDirty,
};
const shouldDisplayMonitoringBadge =
queryMode === 'Logs' && isMonitoringAccount && config.featureToggles.cloudWatchCrossAccountQuerying;
config.featureToggles.cloudWatchCrossAccountQuerying &&
isMonitoringAccount &&
(query.queryMode === 'Logs' ||
(isCloudWatchMetricsQuery(query) && query.metricQueryType === MetricQueryType.Search));
return (
<EditorHeader>
<InlineSelect
label="Region"
value={region}
placeholder="Select region"
allowCustomValue
onChange={({ value: region }) => region && onRegionChange(region)}
options={regions}
isLoading={regionIsLoading}
/>
<InlineSelect aria-label="Query mode" value={queryMode} options={apiModes} onChange={onQueryModeChange} />
{shouldDisplayMonitoringBadge && (
<>
<FlexItem grow={1} />
<Badge
text="Monitoring account"
color="blue"
tooltip="AWS monitoring accounts view data from source accounts so you can centralize monitoring and troubleshoot activites"
></Badge>
</>
)}
{queryMode === ExploreMode.Metrics && (
<MetricsQueryHeader
query={query}
datasource={datasource}
onChange={onChange}
onRunQuery={onRunQuery}
isMonitoringAccount={isMonitoringAccount}
sqlCodeEditorIsDirty={sqlCodeEditorIsDirty}
<>
<EditorHeader>
<InlineSelect
label="Region"
value={region}
placeholder="Select region"
allowCustomValue
onChange={({ value: region }) => region && onRegionChange(region)}
options={regions}
isLoading={regionIsLoading}
/>
)}
</EditorHeader>
<InlineSelect
aria-label="Query mode"
value={queryMode}
options={apiModes}
onChange={onQueryModeChange}
inputId={`cloudwatch-query-mode-${query.refId}`}
id={`cloudwatch-query-mode-${query.refId}`}
/>
{extraHeaderElementLeft}
<FlexItem grow={1} />
{shouldDisplayMonitoringBadge && (
<>
<Badge
text="Monitoring account"
color="blue"
tooltip="AWS monitoring accounts view data from source accounts so you can centralize monitoring and troubleshoot activites"
></Badge>
</>
)}
<Button
variant={dataIsStale ? 'primary' : 'secondary'}
size="sm"
onClick={onRunQuery}
icon={data?.state === LoadingState.Loading ? 'fa fa-spinner' : undefined}
disabled={data?.state === LoadingState.Loading}
>
Run queries
</Button>
{extraHeaderElementRight}
</EditorHeader>
</>
);
};

View File

@ -32,7 +32,6 @@ describe('Cloudwatch SQLBuilderEditor', () => {
query: makeSQLQuery(),
datasource,
onChange: () => {},
onRunQuery: () => {},
};
it('Displays the namespace', async () => {

View File

@ -17,10 +17,9 @@ export type Props = {
query: CloudWatchMetricsQuery;
datasource: CloudWatchDatasource;
onChange: (value: CloudWatchMetricsQuery) => void;
onRunQuery: () => void;
};
export function SQLBuilderEditor({ query, datasource, onChange, onRunQuery }: React.PropsWithChildren<Props>) {
export function SQLBuilderEditor({ query, datasource, onChange }: React.PropsWithChildren<Props>) {
const sql = query.sql ?? {};
const onQueryChange = useCallback(
@ -33,9 +32,8 @@ export function SQLBuilderEditor({ query, datasource, onChange, onRunQuery }: Re
};
onChange(fullQuery);
onRunQuery();
},
[onChange, onRunQuery]
[onChange]
);
const [sqlPreview, setSQLPreview] = useState<string | undefined>();

View File

@ -12,11 +12,10 @@ 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 }) => {
export const SQLCodeEditor: FunctionComponent<Props> = ({ region, sql, onChange, datasource }) => {
useEffect(() => {
datasource.sqlCompletionItemProvider.setRegion(region);
}, [region, datasource]);
@ -27,10 +26,9 @@ export const SQLCodeEditor: FunctionComponent<Props> = ({ region, sql, onChange,
editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => {
const text = editor.getValue();
onChange(text);
onRunQuery();
});
},
[onChange, onRunQuery]
[onChange]
);
return (

View File

@ -1,8 +1,7 @@
export { Dimensions } from './Dimensions/Dimensions';
export { QueryInlineField, QueryField } from './Forms';
export { PanelQueryEditor } from './PanelQueryEditor';
export { QueryEditor as PanelQueryEditor } from './QueryEditor';
export { CloudWatchLogsQueryEditor } from './LogsQueryEditor';
export { MetricStatEditor } from './MetricStatEditor';
export { SQLBuilderEditor } from './SQLBuilderEditor';
export { MathExpressionQueryField } from './MathExpressionQueryField';
export { SQLCodeEditor } from './SQLCodeEditor';

View File

@ -159,7 +159,7 @@ export class CloudWatchDatasource
}
getQueryDisplayText(query: CloudWatchQuery) {
if (query.queryMode === 'Logs') {
if (isCloudWatchLogsQuery(query)) {
return query.expression ?? '';
} else {
return JSON.stringify(query);

View File

@ -4,7 +4,7 @@ import { getAppEvents } from '@grafana/runtime';
import { ConfigEditor } from './components/ConfigEditor';
import LogsCheatSheet from './components/LogsCheatSheet';
import { MetaInspector } from './components/MetaInspector';
import { PanelQueryEditor } from './components/PanelQueryEditor';
import { QueryEditor } from './components/QueryEditor';
import { CloudWatchDatasource } from './datasource';
import { onDashboardLoadedHandler } from './tracking';
import { CloudWatchJsonData, CloudWatchQuery } from './types';
@ -14,7 +14,7 @@ export const plugin = new DataSourcePlugin<CloudWatchDatasource, CloudWatchQuery
)
.setQueryEditorHelp(LogsCheatSheet)
.setConfigEditor(ConfigEditor)
.setQueryEditor(PanelQueryEditor)
.setQueryEditor(QueryEditor)
.setMetadataInspector(MetaInspector);
// Subscribe to on dashboard loaded event so that we can track plugin adoption

View File

@ -41,7 +41,7 @@ export interface SQLExpression {
}
export interface CloudWatchMetricsQuery extends MetricStat, DataQuery {
queryMode?: 'Metrics';
queryMode?: CloudWatchQueryMode;
metricQueryType?: MetricQueryType;
metricEditorMode?: MetricEditorMode;
@ -96,7 +96,7 @@ export enum CloudWatchLogsQueryStatus {
}
export interface CloudWatchLogsQuery extends DataQuery {
queryMode: 'Logs';
queryMode: CloudWatchQueryMode;
id: string;
region: string;
expression?: string;
@ -115,7 +115,7 @@ export type CloudWatchQuery =
| CloudWatchDefaultQuery;
export interface CloudWatchAnnotationQuery extends MetricStat, DataQuery {
queryMode: 'Annotations';
queryMode: CloudWatchQueryMode;
prefixMatching?: boolean;
actionPrefix?: string;
alarmNamePrefix?: string;