From 87dc64e0bff3e1cceb95557ba1bcd974c733c371 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 14 Sep 2023 10:55:09 +0300 Subject: [PATCH] [ES|QL] Enable ESQL alerts from the Discover app (#165973) ## Summary Enables the Alerts menu in Discover nav for the ES|QL mode and defaults to ESQL alerts by carrying the query that the user has typed. image ### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../src/__mocks__/data_view.ts | 4 ++ .../__mocks__/data_view_no_timefield.ts | 54 +++++++++++++++++++ .../components/top_nav/get_top_nav_links.tsx | 2 +- .../top_nav/open_alerts_popover.test.tsx | 48 +++++++++++++---- .../top_nav/open_alerts_popover.tsx | 27 +++++++++- .../view_alert/view_alert_route.tsx | 17 ++++-- .../view_alert/view_alert_utils.tsx | 33 +++++++++--- .../apps/discover/group2/_esql_view.ts | 2 +- .../expression/esql_query_expression.test.tsx | 2 + .../expression/esql_query_expression.tsx | 5 ++ 10 files changed, 170 insertions(+), 24 deletions(-) create mode 100644 src/plugins/discover/public/__mocks__/data_view_no_timefield.ts diff --git a/packages/kbn-discover-utils/src/__mocks__/data_view.ts b/packages/kbn-discover-utils/src/__mocks__/data_view.ts index 9175fe655b974..66e2803cdd239 100644 --- a/packages/kbn-discover-utils/src/__mocks__/data_view.ts +++ b/packages/kbn-discover-utils/src/__mocks__/data_view.ts @@ -92,6 +92,10 @@ export const buildDataViewMock = ({ return dataViewFields.find((field) => field.name === fieldName); }; + dataViewFields.getByType = (type: string) => { + return dataViewFields.filter((field) => field.type === type); + }; + dataViewFields.getAll = () => { return dataViewFields; }; diff --git a/src/plugins/discover/public/__mocks__/data_view_no_timefield.ts b/src/plugins/discover/public/__mocks__/data_view_no_timefield.ts new file mode 100644 index 0000000000000..ebfc810f1de28 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/data_view_no_timefield.ts @@ -0,0 +1,54 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DataView } from '@kbn/data-views-plugin/public'; +import { buildDataViewMock } from '@kbn/discover-utils/src/__mocks__'; + +const fields = [ + { + name: '_index', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'message', + displayName: 'message', + type: 'string', + scripted: false, + filterable: false, + }, + { + name: 'extension', + displayName: 'extension', + type: 'string', + scripted: false, + filterable: true, + aggregatable: true, + }, + { + name: 'bytes', + displayName: 'bytes', + type: 'number', + scripted: false, + filterable: true, + aggregatable: true, + }, + { + name: 'scripted', + displayName: 'scripted', + type: 'number', + scripted: true, + filterable: false, + }, +] as DataView['fields']; + +export const dataViewWithNoTimefieldMock = buildDataViewMock({ + name: 'index-pattern-with-timefield', + fields, +}); diff --git a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx index 2b406e6a45682..ef2a6fb9ad28b 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/get_top_nav_links.tsx @@ -53,6 +53,7 @@ export const getTopNavLinks = ({ services, stateContainer: state, adHocDataViews, + isPlainRecord, }); }, testId: 'discoverAlertsButton', @@ -232,7 +233,6 @@ export const getTopNavLinks = ({ if ( services.triggersActionsUi && services.capabilities.management?.insightsAndAlerting?.triggersActions && - !isPlainRecord && !defaultMenu?.alertsItem?.disabled ) { entries.push({ data: alerts, order: defaultMenu?.alertsItem?.order ?? 400 }); diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx index 03e5712df5e2e..bea933950200d 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.test.tsx @@ -13,10 +13,11 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { AlertsPopover } from './open_alerts_popover'; import { discoverServiceMock } from '../../../../__mocks__/services'; import { dataViewWithTimefieldMock } from '../../../../__mocks__/data_view_with_timefield'; +import { dataViewWithNoTimefieldMock } from '../../../../__mocks__/data_view_no_timefield'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; -const mount = (dataView = dataViewMock) => { +const mount = (dataView = dataViewMock, isPlainRecord = false) => { const stateContainer = getDiscoverStateMock({ isTimeBased: true }); stateContainer.actions.setDataView(dataView); return mountWithIntl( @@ -25,6 +26,7 @@ const mount = (dataView = dataViewMock) => { stateContainer={stateContainer} anchorElement={document.createElement('div')} adHocDataViews={[]} + isPlainRecord={isPlainRecord} services={discoverServiceMock} onClose={jest.fn()} /> @@ -33,18 +35,42 @@ const mount = (dataView = dataViewMock) => { }; describe('OpenAlertsPopover', () => { - it('should render with the create search threshold rule button disabled if the data view has no time field', () => { - const component = mount(); - expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeTruthy(); - }); + describe('Dataview mode', () => { + it('should render with the create search threshold rule button disabled if the data view has no time field', () => { + const component = mount(); + expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeTruthy(); + }); + + it('should render with the create search threshold rule button enabled if the data view has a time field', () => { + const component = mount(dataViewWithTimefieldMock); + expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeFalsy(); + }); - it('should render with the create search threshold rule button enabled if the data view has a time field', () => { - const component = mount(dataViewWithTimefieldMock); - expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeFalsy(); + it('should render the manage rules and connectors link', () => { + const component = mount(); + expect(findTestSubject(component, 'discoverManageAlertsButton').exists()).toBeTruthy(); + }); }); - it('should render the manage rules and connectors link', () => { - const component = mount(); - expect(findTestSubject(component, 'discoverManageAlertsButton').exists()).toBeTruthy(); + describe('ES|QL mode', () => { + it('should render with the create search threshold rule button enabled if the data view has no timeFieldName but at least one time field', () => { + const component = mount(dataViewMock, true); + expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeFalsy(); + }); + + it('should render with the create search threshold rule button enabled if the data view has a time field', () => { + const component = mount(dataViewWithTimefieldMock, true); + expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeFalsy(); + }); + + it('should render with the create search threshold rule button disabled if the data view has no time fields at all', () => { + const component = mount(dataViewWithNoTimefieldMock, true); + expect(findTestSubject(component, 'discoverCreateAlertButton').prop('disabled')).toBeTruthy(); + }); + + it('should render the manage rules and connectors link', () => { + const component = mount(); + expect(findTestSubject(component, 'discoverManageAlertsButton').exists()).toBeTruthy(); + }); }); }); diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx index 75202710945dd..2993193ccc2bf 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx @@ -28,6 +28,7 @@ interface AlertsPopoverProps { savedQueryId?: string; adHocDataViews: DataView[]; services: DiscoverServices; + isPlainRecord?: boolean; } interface EsQueryAlertMetaData { @@ -41,8 +42,13 @@ export function AlertsPopover({ services, stateContainer, onClose: originalOnClose, + isPlainRecord, }: AlertsPopoverProps) { const dataView = stateContainer.internalState.getState().dataView; + const query = stateContainer.appState.getState().query; + const dateFields = dataView?.fields.getByType('date'); + const timeField = dataView?.timeFieldName || dateFields?.[0]?.name; + const { triggersActionsUi } = services; const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); const onClose = useCallback(() => { @@ -54,6 +60,13 @@ export function AlertsPopover({ * Provides the default parameters used to initialize the new rule */ const getParams = useCallback(() => { + if (isPlainRecord) { + return { + searchType: 'esqlQuery', + esqlQuery: query, + timeField, + }; + } const savedQueryId = stateContainer.appState.getState().savedQuery; return { searchType: 'searchSource', @@ -62,7 +75,7 @@ export function AlertsPopover({ .searchSource.getSerializedFields(), savedQueryId, }; - }, [stateContainer]); + }, [isPlainRecord, stateContainer.appState, stateContainer.savedSearchState, query, timeField]); const discoverMetadata: EsQueryAlertMetaData = useMemo( () => ({ @@ -98,7 +111,14 @@ export function AlertsPopover({ }); }, [alertFlyoutVisible, triggersActionsUi, discoverMetadata, getParams, onClose, stateContainer]); - const hasTimeFieldName = Boolean(dataView?.timeFieldName); + const hasTimeFieldName: boolean = useMemo(() => { + if (!isPlainRecord) { + return Boolean(dataView?.timeFieldName); + } else { + return Boolean(timeField); + } + }, [dataView?.timeFieldName, isPlainRecord, timeField]); + const panels = [ { id: 'mainPanel', @@ -165,11 +185,13 @@ export function openAlertsPopover({ stateContainer, services, adHocDataViews, + isPlainRecord, }: { anchorElement: HTMLElement; stateContainer: DiscoverStateContainer; services: DiscoverServices; adHocDataViews: DataView[]; + isPlainRecord?: boolean; }) { if (isOpen) { closeAlertsPopover(); @@ -188,6 +210,7 @@ export function openAlertsPopover({ stateContainer={stateContainer} adHocDataViews={adHocDataViews} services={services} + isPlainRecord={isPlainRecord} /> diff --git a/src/plugins/discover/public/application/view_alert/view_alert_route.tsx b/src/plugins/discover/public/application/view_alert/view_alert_route.tsx index 48b7144b9115d..5d8383be5e935 100644 --- a/src/plugins/discover/public/application/view_alert/view_alert_route.tsx +++ b/src/plugins/discover/public/application/view_alert/view_alert_route.tsx @@ -20,7 +20,7 @@ const isActualAlert = (queryParams: QueryParams): queryParams is NonNullableEntr }; export function ViewAlertRoute() { - const { core, data, locator, toastNotifications } = useDiscoverServices(); + const { core, data, locator, toastNotifications, dataViews } = useDiscoverServices(); const { id } = useParams<{ id: string }>(); const history = useHistory(); const { search } = useLocation(); @@ -46,7 +46,8 @@ export function ViewAlertRoute() { queryParams, toastNotifications, core, - data + data, + dataViews ); const navigateWithDiscoverState = (state: DiscoverAppLocatorParams) => { @@ -63,7 +64,17 @@ export function ViewAlertRoute() { .then(buildLocatorParams) .then(navigateWithDiscoverState) .catch(navigateToDiscoverRoot); - }, [core, data, history, id, locator, openActualAlert, queryParams, toastNotifications]); + }, [ + core, + data, + dataViews, + history, + id, + locator, + openActualAlert, + queryParams, + toastNotifications, + ]); return null; } diff --git a/src/plugins/discover/public/application/view_alert/view_alert_utils.tsx b/src/plugins/discover/public/application/view_alert/view_alert_utils.tsx index d8a7bfeae9b34..cc51f11885ea4 100644 --- a/src/plugins/discover/public/application/view_alert/view_alert_utils.tsx +++ b/src/plugins/discover/public/application/view_alert/view_alert_utils.tsx @@ -8,8 +8,9 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { getIndexPatternFromESQLQuery, type AggregateQuery } from '@kbn/es-query'; import { CoreStart, ToastsStart } from '@kbn/core/public'; -import type { DataView } from '@kbn/data-views-plugin/public'; +import type { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { Rule } from '@kbn/alerting-plugin/common'; import type { RuleTypeParams } from '@kbn/alerting-plugin/common'; import { ISearchSource, SerializedSearchSourceFields, getTime } from '@kbn/data-plugin/common'; @@ -21,6 +22,8 @@ import { DiscoverAppLocatorParams } from '../../../common/locator'; export interface SearchThresholdAlertParams extends RuleTypeParams { searchConfiguration: SerializedSearchSourceFields; + esqlQuery?: AggregateQuery; + timeField?: string; } export interface QueryParams { @@ -50,7 +53,8 @@ export const getAlertUtils = ( queryParams: QueryParams, toastNotifications: ToastsStart, core: CoreStart, - data: DataPublicPluginStart + data: DataPublicPluginStart, + dataViews: DataViewsPublicPluginStart ) => { const showDataViewFetchError = (alertId: string) => { const errorTitle = i18n.translate('discover.viewAlert.dataViewErrorTitle', { @@ -111,14 +115,31 @@ export const getAlertUtils = ( } }; - const buildLocatorParams = ({ + const buildLocatorParams = async ({ alert, searchSource, }: { alert: Rule; searchSource: ISearchSource; - }): DiscoverAppLocatorParams => { - const dataView = searchSource.getField('index'); + }): Promise => { + let dataView = searchSource.getField('index'); + let query = searchSource.getField('query') || data.query.queryString.getDefaultQuery(); + + // Dataview and query for ES|QL alerts + if ( + alert.params && + 'esqlQuery' in alert.params && + alert.params.esqlQuery && + 'esql' in alert.params.esqlQuery + ) { + query = alert.params.esqlQuery; + const indexPattern: string = getIndexPatternFromESQLQuery(alert.params.esqlQuery.esql); + dataView = await dataViews.create({ + title: indexPattern, + timeFieldName: alert.params.timeField, + }); + } + const timeFieldName = dataView?.timeFieldName; // data view fetch error if (!dataView || !timeFieldName) { @@ -131,7 +152,7 @@ export const getAlertUtils = ( : buildTimeRangeFilter(dataView, alert, timeFieldName); return { - query: searchSource.getField('query') || data.query.queryString.getDefaultQuery(), + query, dataViewSpec: dataView.toSpec(false), timeRange, filters: searchSource.getField('filter') as Filter[], diff --git a/test/functional/apps/discover/group2/_esql_view.ts b/test/functional/apps/discover/group2/_esql_view.ts index 1f7493b6ff905..d820841b16cd0 100644 --- a/test/functional/apps/discover/group2/_esql_view.ts +++ b/test/functional/apps/discover/group2/_esql_view.ts @@ -75,7 +75,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // when Lens suggests a table, we render an ESQL based histogram expect(await testSubjects.exists('unifiedHistogramChart')).to.be(true); expect(await testSubjects.exists('unifiedHistogramQueryHits')).to.be(true); - expect(await testSubjects.exists('discoverAlertsButton')).to.be(false); + expect(await testSubjects.exists('discoverAlertsButton')).to.be(true); expect(await testSubjects.exists('shareTopNavButton')).to.be(true); expect(await testSubjects.exists('dataGridColumnSortingButton')).to.be(false); expect(await testSubjects.exists('docTableExpandToggleColumn')).to.be(true); diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.test.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.test.tsx index b9b8ba0dd38f7..025ac9deac732 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.test.tsx @@ -25,6 +25,7 @@ jest.mock('@kbn/text-based-editor', () => ({ fetchFieldsFromESQL: jest.fn(), })); const { fetchFieldsFromESQL } = jest.requireMock('@kbn/text-based-editor'); +const { getFields } = jest.requireMock('@kbn/triggers-actions-ui-plugin/public'); const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => ( {children} @@ -133,6 +134,7 @@ describe('EsqlQueryRuleTypeExpression', () => { }, ], }); + getFields.mockResolvedValue([]); const result = render( { setRuleProperty('params', currentRuleParams); setQuery(esqlQuery ?? { esql: '' }); + if (esqlQuery && 'esql' in esqlQuery) { + if (esqlQuery.esql) { + refreshTimeFields(esqlQuery); + } + } if (timeField) { setTimeFieldOptions([firstFieldOption, { text: timeField, value: timeField }]); }