mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Discover][Alerting] Add test button to rule flyout (#132540)
This commit is contained in:
parent
906569f679
commit
96c988abce
6 changed files with 264 additions and 97 deletions
|
@ -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}>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue