[Discover][Alerting] Add test button to rule flyout (#132540)

This commit is contained in:
Matthias Wilhelm 2022-05-23 16:12:09 +02:00 committed by GitHub
parent 906569f679
commit 96c988abce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 264 additions and 97 deletions

View file

@ -16,16 +16,13 @@ import 'brace/theme/github';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiSpacer,
EuiFormRow,
EuiText,
EuiTitle,
EuiLink,
EuiIconTip,
} from '@elastic/eui';
import { DocLinksStart, HttpSetup } from '@kbn/core/public';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { XJson, EuiCodeEditor } from '@kbn/es-ui-shared-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
@ -42,10 +39,8 @@ import { buildSortedEventsQuery } from '../../../../common/build_sorted_events_q
import { EsQueryAlertParams, SearchType } from '../types';
import { IndexSelectPopover } from '../../components/index_select_popover';
import { DEFAULT_VALUES } from '../constants';
function totalHitsToNumber(total: estypes.SearchHitsMetadata['total']): number {
return typeof total === 'number' ? total : total?.value ?? 0;
}
import { TestQueryRow } from './test_query_row';
import { totalHitsToNumber } from './use_test_query';
const { useXJsonMode } = XJson;
const xJsonMode = new XJsonMode();
@ -109,8 +104,6 @@ export const EsQueryExpression = ({
}>
>([]);
const { convertToJson, setXJson, xJson } = useXJsonMode(DEFAULT_VALUES.QUERY);
const [testQueryResult, setTestQueryResult] = useState<string | null>(null);
const [testQueryError, setTestQueryError] = useState<string | null>(null);
const setDefaultExpressionValues = async () => {
setRuleProperty('params', currentAlertParams);
@ -133,55 +126,39 @@ export const EsQueryExpression = ({
}
};
const hasValidationErrors = () => {
const hasValidationErrors = useCallback(() => {
const { errors: validationErrors } = validateExpression(currentAlertParams);
return Object.keys(validationErrors).some(
(key) => validationErrors[key] && validationErrors[key].length
);
};
}, [currentAlertParams]);
const onTestQuery = async () => {
if (!hasValidationErrors()) {
setTestQueryError(null);
setTestQueryResult(null);
try {
const window = `${timeWindowSize}${timeWindowUnit}`;
const timeWindow = parseDuration(window);
const parsedQuery = JSON.parse(esQuery);
const now = Date.now();
const { rawResponse } = await firstValueFrom(
data.search.search({
params: buildSortedEventsQuery({
index,
from: new Date(now - timeWindow).toISOString(),
to: new Date(now).toISOString(),
filter: parsedQuery.query,
size: 0,
searchAfterSortId: undefined,
timeField: timeField ? timeField : '',
track_total_hits: true,
}),
})
);
const hits = rawResponse.hits;
setTestQueryResult(
i18n.translate('xpack.stackAlerts.esQuery.ui.numQueryMatchesText', {
defaultMessage: 'Query matched {count} documents in the last {window}.',
values: { count: totalHitsToNumber(hits.total), window },
})
);
} catch (err) {
const message = err?.body?.attributes?.error?.root_cause[0]?.reason || err?.body?.message;
setTestQueryError(
i18n.translate('xpack.stackAlerts.esQuery.ui.queryError', {
defaultMessage: 'Error testing query: {message}',
values: { message: message ? `${err.message}: ${message}` : err.message },
})
);
}
const onTestQuery = useCallback(async () => {
const window = `${timeWindowSize}${timeWindowUnit}`;
if (hasValidationErrors()) {
return { nrOfDocs: 0, timeWindow: window };
}
};
const timeWindow = parseDuration(window);
const parsedQuery = JSON.parse(esQuery);
const now = Date.now();
const { rawResponse } = await firstValueFrom(
data.search.search({
params: buildSortedEventsQuery({
index,
from: new Date(now - timeWindow).toISOString(),
to: new Date(now).toISOString(),
filter: parsedQuery.query,
size: 0,
searchAfterSortId: undefined,
timeField: timeField ? timeField : '',
track_total_hits: true,
}),
})
);
const hits = rawResponse.hits;
return { nrOfDocs: totalHitsToNumber(hits.total), timeWindow: window };
}, [data.search, esQuery, index, timeField, timeWindowSize, timeWindowUnit, hasValidationErrors]);
return (
<Fragment>
@ -281,36 +258,7 @@ export const EsQueryExpression = ({
}}
/>
</EuiFormRow>
<EuiFormRow>
<EuiButtonEmpty
data-test-subj="testQuery"
color={'primary'}
iconSide={'left'}
flush={'left'}
iconType={'play'}
disabled={hasValidationErrors()}
onClick={onTestQuery}
>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.testQuery"
defaultMessage="Test query"
/>
</EuiButtonEmpty>
</EuiFormRow>
{testQueryResult && (
<EuiFormRow>
<EuiText data-test-subj="testQuerySuccess" color="subdued" size="s">
<p>{testQueryResult}</p>
</EuiText>
</EuiFormRow>
)}
{testQueryError && (
<EuiFormRow>
<EuiText data-test-subj="testQueryError" color="danger" size="s">
<p>{testQueryError}</p>
</EuiText>
</EuiFormRow>
)}
<TestQueryRow fetch={onTestQuery} hasValidationErrors={hasValidationErrors()} />
<EuiSpacer />
<EuiFlexGroup alignItems="center" responsive={false} gutterSize="none">
<EuiFlexItem grow={false}>

View file

@ -14,12 +14,19 @@ import { EsQueryAlertParams, SearchType } from '../types';
import { SearchSourceExpression } from './search_source_expression';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { act } from 'react-dom/test-utils';
import { of } from 'rxjs';
import { IKibanaSearchResponse, ISearchSource } from '@kbn/data-plugin/common';
import { IUiSettingsClient } from '@kbn/core/public';
import { findTestSubject } from '@elastic/eui/lib/test';
import { EuiLoadingSpinner } from '@elastic/eui';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
const dataViewPluginMock = dataViewPluginMocks.createStartContract();
const chartsStartMock = chartPluginMock.createStartContract();
const unifiedSearchMock = unifiedSearchPluginMock.createStartContract();
export const uiSettingsMock = {
get: jest.fn(),
} as unknown as IUiSettingsClient;
const defaultSearchSourceExpressionParams: EsQueryAlertParams<SearchType.searchSource> = {
size: 100,
@ -52,7 +59,23 @@ const searchSourceMock = {
}
return '';
},
};
setField: jest.fn(),
createCopy: jest.fn(() => {
return searchSourceMock;
}),
setParent: jest.fn(() => {
return searchSourceMock;
}),
fetch$: jest.fn(() => {
return of<IKibanaSearchResponse>({
rawResponse: {
hits: {
total: 1234,
},
},
});
}),
} as unknown as ISearchSource;
const savedQueryMock = {
id: 'test-id',
@ -67,10 +90,6 @@ const savedQueryMock = {
},
};
jest.mock('./search_source_expression_form', () => ({
SearchSourceExpressionForm: () => <div>search source expression form mock</div>,
}));
const dataMock = dataPluginMock.createStartContract();
(dataMock.search.searchSource.create as jest.Mock).mockImplementation(() =>
Promise.resolve(searchSourceMock)
@ -79,6 +98,9 @@ const dataMock = dataPluginMock.createStartContract();
(dataMock.query.savedQueries.getSavedQuery as jest.Mock).mockImplementation(() =>
Promise.resolve(savedQueryMock)
);
dataMock.query.savedQueries.findSavedQueries = jest.fn(() =>
Promise.resolve({ total: 0, queries: [] })
);
const setup = (alertParams: EsQueryAlertParams<SearchType.searchSource>) => {
const errors = {
@ -88,8 +110,8 @@ const setup = (alertParams: EsQueryAlertParams<SearchType.searchSource>) => {
searchConfiguration: [],
};
const wrapper = mountWithIntl(
<KibanaContextProvider services={{ data: dataMock }}>
return mountWithIntl(
<KibanaContextProvider services={{ data: dataMock, uiSettings: uiSettingsMock }}>
<SearchSourceExpression
ruleInterval="1m"
ruleThrottle="1m"
@ -107,31 +129,49 @@ const setup = (alertParams: EsQueryAlertParams<SearchType.searchSource>) => {
/>
</KibanaContextProvider>
);
return wrapper;
};
describe('SearchSourceAlertTypeExpression', () => {
test('should render correctly', async () => {
let wrapper = setup(defaultSearchSourceExpressionParams).children();
let wrapper = setup(defaultSearchSourceExpressionParams);
expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy();
expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy();
await act(async () => {
await nextTick();
});
wrapper = await wrapper.update();
expect(findTestSubject(wrapper, 'thresholdExpression')).toBeTruthy();
});
test('should show success message if Test Query is successful', async () => {
let wrapper = setup(defaultSearchSourceExpressionParams);
await act(async () => {
await nextTick();
});
wrapper = await wrapper.update();
await act(async () => {
findTestSubject(wrapper, 'testQuery').simulate('click');
wrapper.update();
});
wrapper = await wrapper.update();
expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy();
expect(wrapper.text().includes('search source expression form mock')).toBeTruthy();
await act(async () => {
await nextTick();
wrapper.update();
});
expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy();
expect(wrapper.find('EuiText[data-test-subj="testQuerySuccess"]').text()).toEqual(
`Query matched 1234 documents in the last 15s.`
);
});
test('should render error prompt', async () => {
(dataMock.search.searchSource.create as jest.Mock).mockImplementationOnce(() =>
Promise.reject(new Error('Cant find searchSource'))
);
let wrapper = setup(defaultSearchSourceExpressionParams).children();
let wrapper = setup(defaultSearchSourceExpressionParams);
expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy();
expect(wrapper.text().includes('Cant find searchSource')).toBeFalsy();

View file

@ -7,10 +7,12 @@
import React, { Fragment, useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import { firstValueFrom } from 'rxjs';
import { Filter } from '@kbn/es-query';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Filter, DataView, Query, ISearchSource } from '@kbn/data-plugin/common';
import { DataView, Query, ISearchSource, getTime } from '@kbn/data-plugin/common';
import {
ForLastExpression,
IErrorObject,
@ -24,6 +26,8 @@ import { EsQueryAlertParams, SearchType } from '../types';
import { DEFAULT_VALUES } from '../constants';
import { DataViewSelectPopover } from '../../components/data_view_select_popover';
import { useTriggersAndActionsUiDeps } from '../util';
import { totalHitsToNumber } from './use_test_query';
import { TestQueryRow } from './test_query_row';
interface LocalState {
index: DataView;
@ -161,6 +165,17 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
(updatedValue: number) => dispatch({ type: 'size', payload: updatedValue }),
[]
);
const onTestFetch = useCallback(async () => {
const timeWindow = `${timeWindowSize}${timeWindowUnit}`;
const testSearchSource = searchSource.createCopy();
const timeFilter = getTime(searchSource.getField('index')!, {
from: `now-${timeWindow}`,
to: 'now',
});
testSearchSource.setField('filter', timeFilter);
const { rawResponse } = await firstValueFrom(testSearchSource.fetch$());
return { nrOfDocs: totalHitsToNumber(rawResponse.hits.total), timeWindow };
}, [searchSource, timeWindowSize, timeWindowUnit]);
return (
<Fragment>
@ -264,6 +279,8 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp
onChangeSelectedValue={onChangeSizeValue}
/>
<EuiSpacer size="s" />
<TestQueryRow fetch={onTestFetch} hasValidationErrors={false} />
<EuiSpacer />
</Fragment>
);
};

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButtonEmpty, EuiFormRow, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useTestQuery } from './use_test_query';
export function TestQueryRow({
fetch,
hasValidationErrors,
}: {
fetch: () => Promise<{ nrOfDocs: number; timeWindow: string }>;
hasValidationErrors: boolean;
}) {
const { onTestQuery, testQueryResult, testQueryError, testQueryLoading } = useTestQuery(fetch);
return (
<>
<EuiFormRow>
<EuiButtonEmpty
data-test-subj="testQuery"
color="primary"
iconSide="left"
flush="left"
iconType="play"
onClick={onTestQuery}
disabled={hasValidationErrors}
isLoading={testQueryLoading}
>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.testQuery"
defaultMessage="Test query"
/>
</EuiButtonEmpty>
</EuiFormRow>
{testQueryLoading && (
<EuiFormRow>
<EuiText color="subdued" size="s">
<p>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.testQueryIsExecuted"
defaultMessage="Query is executed."
/>
</p>
</EuiText>
</EuiFormRow>
)}
{testQueryResult && (
<EuiFormRow>
<EuiText data-test-subj="testQuerySuccess" color="subdued" size="s">
<p>{testQueryResult}</p>
</EuiText>
</EuiFormRow>
)}
{testQueryError && (
<EuiFormRow>
<EuiText data-test-subj="testQueryError" color="danger" size="s">
<p>{testQueryError}</p>
</EuiText>
</EuiFormRow>
)}
</>
);
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { act } from 'react-test-renderer';
import { useTestQuery } from './use_test_query';
describe('useTestQuery', () => {
test('returning a valid result', async () => {
const { result } = renderHook(useTestQuery, {
initialProps: () => Promise.resolve({ nrOfDocs: 1, timeWindow: '1s' }),
});
await act(async () => {
await result.current.onTestQuery();
});
expect(result.current.testQueryLoading).toBe(false);
expect(result.current.testQueryError).toBe(null);
expect(result.current.testQueryResult).toContain('1s');
expect(result.current.testQueryResult).toContain('1 document');
});
test('returning an error', async () => {
const errorMsg = 'How dare you writing such a query';
const { result } = renderHook(useTestQuery, {
initialProps: () => Promise.reject({ message: errorMsg }),
});
await act(async () => {
await result.current.onTestQuery();
});
expect(result.current.testQueryLoading).toBe(false);
expect(result.current.testQueryError).toContain(errorMsg);
expect(result.current.testQueryResult).toBe(null);
});
});

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useState, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
/**
* Hook used to test the data fetching execution by returning a number of found documents
* Or in error in case it's failing
*/
export function useTestQuery(fetch: () => Promise<{ nrOfDocs: number; timeWindow: string }>) {
const [testQueryResult, setTestQueryResult] = useState<string | null>(null);
const [testQueryError, setTestQueryError] = useState<string | null>(null);
const [testQueryLoading, setTestQueryLoading] = useState<boolean>(false);
const onTestQuery = useCallback(async () => {
setTestQueryLoading(true);
setTestQueryError(null);
setTestQueryResult(null);
try {
const { nrOfDocs, timeWindow } = await fetch();
setTestQueryResult(
i18n.translate('xpack.stackAlerts.esQuery.ui.numQueryMatchesText', {
defaultMessage: 'Query matched {count} documents in the last {window}.',
values: { count: nrOfDocs, window: timeWindow },
})
);
} catch (err) {
const message = err?.body?.attributes?.error?.root_cause[0]?.reason || err?.body?.message;
setTestQueryError(
i18n.translate('xpack.stackAlerts.esQuery.ui.queryError', {
defaultMessage: 'Error testing query: {message}',
values: { message: message ? `${err.message}: ${message}` : err.message },
})
);
} finally {
setTestQueryLoading(false);
}
}, [fetch]);
return {
onTestQuery,
testQueryResult,
testQueryError,
testQueryLoading,
};
}
export function totalHitsToNumber(total: estypes.SearchHitsMetadata['total']): number {
return typeof total === 'number' ? total : total?.value ?? 0;
}