Skip to content

Commit

Permalink
[ES|QL] Enable ESQL alerts from the Discover app (#165973)
Browse files Browse the repository at this point in the history
## 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.

<img width="1621" alt="image"
src="https://github.com/elastic/kibana/assets/17003240/5ffef9d1-179a-464a-8941-b6bf18b4f30f">

### 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
  • Loading branch information
stratoula authored Sep 14, 2023
1 parent dcce011 commit 87dc64e
Show file tree
Hide file tree
Showing 10 changed files with 170 additions and 24 deletions.
4 changes: 4 additions & 0 deletions packages/kbn-discover-utils/src/__mocks__/data_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
54 changes: 54 additions & 0 deletions src/plugins/discover/public/__mocks__/data_view_no_timefield.ts
Original file line number Diff line number Diff line change
@@ -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,
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const getTopNavLinks = ({
services,
stateContainer: state,
adHocDataViews,
isPlainRecord,
});
},
testId: 'discoverAlertsButton',
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -25,6 +26,7 @@ const mount = (dataView = dataViewMock) => {
stateContainer={stateContainer}
anchorElement={document.createElement('div')}
adHocDataViews={[]}
isPlainRecord={isPlainRecord}
services={discoverServiceMock}
onClose={jest.fn()}
/>
Expand All @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface AlertsPopoverProps {
savedQueryId?: string;
adHocDataViews: DataView[];
services: DiscoverServices;
isPlainRecord?: boolean;
}

interface EsQueryAlertMetaData {
Expand All @@ -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(() => {
Expand All @@ -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',
Expand All @@ -62,7 +75,7 @@ export function AlertsPopover({
.searchSource.getSerializedFields(),
savedQueryId,
};
}, [stateContainer]);
}, [isPlainRecord, stateContainer.appState, stateContainer.savedSearchState, query, timeField]);

const discoverMetadata: EsQueryAlertMetaData = useMemo(
() => ({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -165,11 +185,13 @@ export function openAlertsPopover({
stateContainer,
services,
adHocDataViews,
isPlainRecord,
}: {
anchorElement: HTMLElement;
stateContainer: DiscoverStateContainer;
services: DiscoverServices;
adHocDataViews: DataView[];
isPlainRecord?: boolean;
}) {
if (isOpen) {
closeAlertsPopover();
Expand All @@ -188,6 +210,7 @@ export function openAlertsPopover({
stateContainer={stateContainer}
adHocDataViews={adHocDataViews}
services={services}
isPlainRecord={isPlainRecord}
/>
</KibanaContextProvider>
</KibanaRenderContextProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -46,7 +46,8 @@ export function ViewAlertRoute() {
queryParams,
toastNotifications,
core,
data
data,
dataViews
);

const navigateWithDiscoverState = (state: DiscoverAppLocatorParams) => {
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +22,8 @@ import { DiscoverAppLocatorParams } from '../../../common/locator';

export interface SearchThresholdAlertParams extends RuleTypeParams {
searchConfiguration: SerializedSearchSourceFields;
esqlQuery?: AggregateQuery;
timeField?: string;
}

export interface QueryParams {
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -111,14 +115,31 @@ export const getAlertUtils = (
}
};

const buildLocatorParams = ({
const buildLocatorParams = async ({
alert,
searchSource,
}: {
alert: Rule<SearchThresholdAlertParams>;
searchSource: ISearchSource;
}): DiscoverAppLocatorParams => {
const dataView = searchSource.getField('index');
}): Promise<DiscoverAppLocatorParams> => {
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) {
Expand All @@ -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[],
Expand Down
2 changes: 1 addition & 1 deletion test/functional/apps/discover/group2/_esql_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<I18nProvider>{children}</I18nProvider>
Expand Down Expand Up @@ -133,6 +134,7 @@ describe('EsqlQueryRuleTypeExpression', () => {
},
],
});
getFields.mockResolvedValue([]);
const result = render(
<EsqlQueryExpression
unifiedSearch={unifiedSearchMock}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export const EsqlQueryExpression: React.FC<
const setDefaultExpressionValues = async () => {
setRuleProperty('params', currentRuleParams);
setQuery(esqlQuery ?? { esql: '' });
if (esqlQuery && 'esql' in esqlQuery) {
if (esqlQuery.esql) {
refreshTimeFields(esqlQuery);
}
}
if (timeField) {
setTimeFieldOptions([firstFieldOption, { text: timeField, value: timeField }]);
}
Expand Down

0 comments on commit 87dc64e

Please sign in to comment.