mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Discover] Extend Elasticsearch query rule with search source based data fetching (#124534)
* [Discover] introduce .index-threshold rule * [Discover] change filters in alert expression * [Discover] fix cursor issue * [Discover] add loading * [Discover] separate validation params * [Discover] add view alert route * [Discover] enable "view in app" for alert created from discover * [Discover] fix filter popover * [Discover] fix linting, unit tests * [Discover] fix remaining tests * [Discover] add unit tests, add link back to stack management for es query * Update src/plugins/discover/public/application/view_alert/view_alert_route.tsx * [Discover] add tool tip for data view without time field * [Discover] add info alert about possible document difference that triggered alert and displayed documents * [Discover] update unit test * [Discover] fix unit tests * Update x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * [Discover] fix unit tests * [Discover] fix security solution alerts * [Discover] fix eslint errors * [Discover] fix unit tests * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * [Discover] apply suggestions * [Discover] fix tests * Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts * [Discover] remove close button in filters * Improve code structure * Fix missing name in fetchEsQuery * Fix messages * Fix messages, again * Refactor * Refactor, add tests + a bit more of documentation * Move size field, change text * Implement readonly callout * change icon in callout * add padding to popover * Hide query and filter UI if there are no values to display * [Discover] add unit test, improve comparator types * [Discover] fix linting and unit test * [Discover] add es query alert integration tests * [Discover] fix linting * [Discover] uncomment one expect * [Discover] fix latesTimestamp for searchSource type, unify test logic * Update x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * [Discover] apply suggestions * [Discover] make searchType optional, adjust tests * [Discover] remove updated translations * [Discover] apply suggestions * [Discover] fix unit test * [Discover] close popover on alert rule creation * [Discover] apply suggestions * [Discover] add first functional test * [Discover] implement tests * Move functionals x-pack since ssl is needed * Fix potential flakiness in functional test * [Discover] remove timeout waiter * Fix functional test - adding permissions to fix the functional * [Discover] add logger * [Discover] add more log points * [Discover] wait for indices creation finished * Try to fix the functional flakiness - by creating data views in a serial way - lets see if that work Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> Co-authored-by: Matthias Wilhelm <matthias.wilhelm@elastic.co> Co-authored-by: andreadelrio <andrea.delrio@elastic.co>
This commit is contained in:
parent
f9d83f9b8b
commit
0427952e76
84 changed files with 3578 additions and 1305 deletions
|
@ -20,7 +20,12 @@ export * from './deprecated';
|
|||
|
||||
export { getEsQueryConfig, FilterStateStore } from '../common';
|
||||
export { FilterLabel, FilterItem } from './ui';
|
||||
export { getDisplayValueFromFilter, generateFilters, extractTimeRange } from './query';
|
||||
export {
|
||||
getDisplayValueFromFilter,
|
||||
generateFilters,
|
||||
extractTimeRange,
|
||||
getIndexPatternFromFilter,
|
||||
} from './query';
|
||||
|
||||
/**
|
||||
* Exporters (CSV)
|
||||
|
|
|
@ -88,3 +88,8 @@
|
|||
.globalFilterItem__popoverAnchor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.globalFilterItem__readonlyPanel {
|
||||
min-width: auto;
|
||||
padding: $euiSizeM;
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ import { IDataPluginServices, IIndexPattern } from '../..';
|
|||
|
||||
import { UI_SETTINGS } from '../../../common';
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
filters: Filter[];
|
||||
onFiltersUpdated?: (filters: Filter[]) => void;
|
||||
className: string;
|
||||
|
|
|
@ -17,11 +17,17 @@ export interface FilterLabelProps {
|
|||
filter: Filter;
|
||||
valueLabel?: string;
|
||||
filterLabelStatus?: FilterLabelStatus;
|
||||
hideAlias?: boolean;
|
||||
}
|
||||
|
||||
// Needed for React.lazy
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function FilterLabel({ filter, valueLabel, filterLabelStatus }: FilterLabelProps) {
|
||||
export default function FilterLabel({
|
||||
filter,
|
||||
valueLabel,
|
||||
filterLabelStatus,
|
||||
hideAlias,
|
||||
}: FilterLabelProps) {
|
||||
const prefixText = filter.meta.negate
|
||||
? ` ${i18n.translate('data.filter.filterBar.negatedFilterPrefix', {
|
||||
defaultMessage: 'NOT ',
|
||||
|
@ -38,7 +44,7 @@ export default function FilterLabel({ filter, valueLabel, filterLabelStatus }: F
|
|||
return <span className="globalFilterLabel__value">{text}</span>;
|
||||
};
|
||||
|
||||
if (filter.meta.alias !== null) {
|
||||
if (!hideAlias && filter.meta.alias !== null) {
|
||||
return (
|
||||
<Fragment>
|
||||
{prefix}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiContextMenu, EuiPopover } from '@elastic/eui';
|
||||
import { EuiContextMenu, EuiPopover, EuiPopoverProps } from '@elastic/eui';
|
||||
import { InjectedIntl } from '@kbn/i18n-react';
|
||||
import {
|
||||
Filter,
|
||||
|
@ -16,7 +16,7 @@ import {
|
|||
toggleFilterDisabled,
|
||||
} from '@kbn/es-query';
|
||||
import classNames from 'classnames';
|
||||
import React, { MouseEvent, useState, useEffect } from 'react';
|
||||
import React, { MouseEvent, useState, useEffect, HTMLAttributes } from 'react';
|
||||
import { IUiSettingsClient } from 'src/core/public';
|
||||
import { FilterEditor } from './filter_editor';
|
||||
import { FilterView } from './filter_view';
|
||||
|
@ -37,8 +37,11 @@ export interface FilterItemProps {
|
|||
uiSettings: IUiSettingsClient;
|
||||
hiddenPanelOptions?: PanelOptions[];
|
||||
timeRangeForSuggestionsOverride?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
type FilterPopoverProps = HTMLAttributes<HTMLDivElement> & EuiPopoverProps;
|
||||
|
||||
interface LabelOptions {
|
||||
title: string;
|
||||
status: FilterLabelStatus;
|
||||
|
@ -349,32 +352,44 @@ export function FilterItem(props: FilterItemProps) {
|
|||
return null;
|
||||
}
|
||||
|
||||
const badge = (
|
||||
<FilterView
|
||||
filter={filter}
|
||||
valueLabel={valueLabelConfig.title}
|
||||
filterLabelStatus={valueLabelConfig.status}
|
||||
errorMessage={valueLabelConfig.message}
|
||||
className={getClasses(filter.meta.negate ?? false, valueLabelConfig)}
|
||||
iconOnClick={() => props.onRemove()}
|
||||
onClick={handleBadgeClick}
|
||||
data-test-subj={getDataTestSubj(valueLabelConfig)}
|
||||
/>
|
||||
);
|
||||
const filterViewProps = {
|
||||
filter,
|
||||
valueLabel: valueLabelConfig.title,
|
||||
filterLabelStatus: valueLabelConfig.status,
|
||||
errorMessage: valueLabelConfig.message,
|
||||
className: getClasses(!!filter.meta.negate, valueLabelConfig),
|
||||
iconOnClick: props.onRemove,
|
||||
onClick: handleBadgeClick,
|
||||
'data-test-subj': getDataTestSubj(valueLabelConfig),
|
||||
readonly: props.readonly,
|
||||
};
|
||||
|
||||
const popoverProps: FilterPopoverProps = {
|
||||
id: `popoverFor_filter${id}`,
|
||||
className: `globalFilterItem__popover`,
|
||||
anchorClassName: `globalFilterItem__popoverAnchor`,
|
||||
isOpen: isPopoverOpen,
|
||||
closePopover: () => {
|
||||
setIsPopoverOpen(false);
|
||||
},
|
||||
button: <FilterView {...filterViewProps} />,
|
||||
panelPaddingSize: 'none',
|
||||
};
|
||||
|
||||
if (props.readonly) {
|
||||
return (
|
||||
<EuiPopover
|
||||
panelClassName="globalFilterItem__readonlyPanel"
|
||||
anchorPosition="upCenter"
|
||||
{...popoverProps}
|
||||
>
|
||||
<FilterView {...filterViewProps} hideAlias />
|
||||
</EuiPopover>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
id={`popoverFor_filter${id}`}
|
||||
className={`globalFilterItem__popover`}
|
||||
anchorClassName={`globalFilterItem__popoverAnchor`}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={() => {
|
||||
setIsPopoverOpen(false);
|
||||
}}
|
||||
button={badge}
|
||||
anchorPosition="downLeft"
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiPopover anchorPosition="downLeft" {...popoverProps}>
|
||||
<EuiContextMenu initialPanelId={0} panels={getPanels()} />
|
||||
</EuiPopover>
|
||||
);
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiBadge, useInnerText } from '@elastic/eui';
|
||||
import { EuiBadge, EuiBadgeProps, useInnerText } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { FC } from 'react';
|
||||
import { Filter, isFilterPinned } from '@kbn/es-query';
|
||||
|
@ -18,6 +18,8 @@ interface Props {
|
|||
valueLabel: string;
|
||||
filterLabelStatus: FilterLabelStatus;
|
||||
errorMessage?: string;
|
||||
readonly?: boolean;
|
||||
hideAlias?: boolean;
|
||||
[propName: string]: any;
|
||||
}
|
||||
|
||||
|
@ -28,6 +30,8 @@ export const FilterView: FC<Props> = ({
|
|||
valueLabel,
|
||||
errorMessage,
|
||||
filterLabelStatus,
|
||||
readonly,
|
||||
hideAlias,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const [ref, innerText] = useInnerText();
|
||||
|
@ -50,33 +54,45 @@ export const FilterView: FC<Props> = ({
|
|||
})} ${title}`;
|
||||
}
|
||||
|
||||
const badgeProps: EuiBadgeProps = readonly
|
||||
? {
|
||||
title,
|
||||
color: 'hollow',
|
||||
onClick,
|
||||
onClickAriaLabel: i18n.translate('data.filter.filterBar.filterItemReadOnlyBadgeAriaLabel', {
|
||||
defaultMessage: 'Filter entry',
|
||||
}),
|
||||
iconOnClick,
|
||||
}
|
||||
: {
|
||||
title,
|
||||
color: 'hollow',
|
||||
iconType: 'cross',
|
||||
iconSide: 'right',
|
||||
closeButtonProps: {
|
||||
// Removing tab focus on close button because the same option can be obtained through the context menu
|
||||
// Also, we may want to add a `DEL` keyboard press functionality
|
||||
tabIndex: -1,
|
||||
},
|
||||
iconOnClick,
|
||||
iconOnClickAriaLabel: i18n.translate('data.filter.filterBar.filterItemBadgeIconAriaLabel', {
|
||||
defaultMessage: 'Delete {filter}',
|
||||
values: { filter: innerText },
|
||||
}),
|
||||
onClick,
|
||||
onClickAriaLabel: i18n.translate('data.filter.filterBar.filterItemBadgeAriaLabel', {
|
||||
defaultMessage: 'Filter actions',
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiBadge
|
||||
title={title}
|
||||
color="hollow"
|
||||
iconType="cross"
|
||||
iconSide="right"
|
||||
closeButtonProps={{
|
||||
// Removing tab focus on close button because the same option can be obtained through the context menu
|
||||
// Also, we may want to add a `DEL` keyboard press functionality
|
||||
tabIndex: -1,
|
||||
}}
|
||||
iconOnClick={iconOnClick}
|
||||
iconOnClickAriaLabel={i18n.translate('data.filter.filterBar.filterItemBadgeIconAriaLabel', {
|
||||
defaultMessage: 'Delete {filter}',
|
||||
values: { filter: innerText },
|
||||
})}
|
||||
onClick={onClick}
|
||||
onClickAriaLabel={i18n.translate('data.filter.filterBar.filterItemBadgeAriaLabel', {
|
||||
defaultMessage: 'Filter actions',
|
||||
})}
|
||||
{...rest}
|
||||
>
|
||||
<EuiBadge {...badgeProps} {...rest}>
|
||||
<span ref={ref}>
|
||||
<FilterLabel
|
||||
filter={filter}
|
||||
valueLabel={valueLabel}
|
||||
filterLabelStatus={filterLabelStatus}
|
||||
hideAlias={hideAlias}
|
||||
/>
|
||||
</span>
|
||||
</EuiBadge>
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
"dataViewFieldEditor",
|
||||
"dataViewEditor"
|
||||
],
|
||||
"optionalPlugins": ["home", "share", "usageCollection", "spaces"],
|
||||
"optionalPlugins": ["home", "share", "usageCollection", "spaces", "triggersActionsUi"],
|
||||
"requiredBundles": ["kibanaUtils", "home", "kibanaReact", "dataViews"],
|
||||
"extraPublicDirs": ["common"],
|
||||
"owner": {
|
||||
|
|
|
@ -16,6 +16,7 @@ import { SingleDocRoute } from './doc';
|
|||
import { DiscoverMainRoute } from './main';
|
||||
import { NotFoundRoute } from './not_found';
|
||||
import { DiscoverServices } from '../build_services';
|
||||
import { ViewAlertRoute } from './view_alert';
|
||||
|
||||
export const discoverRouter = (services: DiscoverServices, history: History) => (
|
||||
<KibanaContextProvider services={services}>
|
||||
|
@ -36,6 +37,9 @@ export const discoverRouter = (services: DiscoverServices, history: History) =>
|
|||
<Route path="/doc/:indexPatternId/:index">
|
||||
<SingleDocRoute />
|
||||
</Route>
|
||||
<Route path="/viewAlert/:id">
|
||||
<ViewAlertRoute />
|
||||
</Route>
|
||||
<Route path="/view/:id">
|
||||
<DiscoverMainRoute />
|
||||
</Route>
|
||||
|
|
|
@ -18,6 +18,7 @@ import { onSaveSearch } from './on_save_search';
|
|||
import { GetStateReturn } from '../../services/discover_state';
|
||||
import { openOptionsPopover } from './open_options_popover';
|
||||
import type { TopNavMenuData } from '../../../../../../navigation/public';
|
||||
import { openAlertsPopover } from './open_alerts_popover';
|
||||
|
||||
/**
|
||||
* Helper function to build the top nav links
|
||||
|
@ -59,6 +60,25 @@ export const getTopNavLinks = ({
|
|||
testId: 'discoverOptionsButton',
|
||||
};
|
||||
|
||||
const alerts = {
|
||||
id: 'alerts',
|
||||
label: i18n.translate('discover.localMenu.localMenu.alertsTitle', {
|
||||
defaultMessage: 'Alerts',
|
||||
}),
|
||||
description: i18n.translate('discover.localMenu.alertsDescription', {
|
||||
defaultMessage: 'Alerts',
|
||||
}),
|
||||
run: (anchorElement: HTMLElement) => {
|
||||
openAlertsPopover({
|
||||
I18nContext: services.core.i18n.Context,
|
||||
anchorElement,
|
||||
searchSource: savedSearch.searchSource,
|
||||
services,
|
||||
});
|
||||
},
|
||||
testId: 'discoverAlertsButton',
|
||||
};
|
||||
|
||||
const newSearch = {
|
||||
id: 'new',
|
||||
label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', {
|
||||
|
@ -162,6 +182,7 @@ export const getTopNavLinks = ({
|
|||
...(services.capabilities.advancedSettings.save ? [options] : []),
|
||||
newSearch,
|
||||
openSearch,
|
||||
...(services.triggersActionsUi ? [alerts] : []),
|
||||
shareSearch,
|
||||
inspectSearch,
|
||||
...(services.capabilities.discover.save ? [saveSearch] : []),
|
|
@ -0,0 +1,185 @@
|
|||
/*
|
||||
* 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 React, { useCallback, useState, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { I18nStart } from 'kibana/public';
|
||||
import { EuiWrappingPopover, EuiLink, EuiContextMenu, EuiToolTip } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { ISearchSource } from '../../../../../../data/common';
|
||||
import { KibanaContextProvider } from '../../../../../../kibana_react/public';
|
||||
import { DiscoverServices } from '../../../../build_services';
|
||||
import { updateSearchSource } from '../../utils/update_search_source';
|
||||
import { useDiscoverServices } from '../../../../utils/use_discover_services';
|
||||
|
||||
const container = document.createElement('div');
|
||||
let isOpen = false;
|
||||
|
||||
const ALERT_TYPE_ID = '.es-query';
|
||||
|
||||
interface AlertsPopoverProps {
|
||||
onClose: () => void;
|
||||
anchorElement: HTMLElement;
|
||||
searchSource: ISearchSource;
|
||||
}
|
||||
|
||||
export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPopoverProps) {
|
||||
const dataView = searchSource.getField('index')!;
|
||||
const services = useDiscoverServices();
|
||||
const { triggersActionsUi } = services;
|
||||
const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false);
|
||||
|
||||
/**
|
||||
* Provides the default parameters used to initialize the new rule
|
||||
*/
|
||||
const getParams = useCallback(() => {
|
||||
const nextSearchSource = searchSource.createCopy();
|
||||
updateSearchSource(nextSearchSource, true, {
|
||||
indexPattern: searchSource.getField('index')!,
|
||||
services,
|
||||
sort: [],
|
||||
useNewFieldsApi: true,
|
||||
});
|
||||
|
||||
return {
|
||||
searchType: 'searchSource',
|
||||
searchConfiguration: nextSearchSource.getSerializedFields(),
|
||||
};
|
||||
}, [searchSource, services]);
|
||||
|
||||
const SearchThresholdAlertFlyout = useMemo(() => {
|
||||
if (!alertFlyoutVisible) {
|
||||
return;
|
||||
}
|
||||
return triggersActionsUi?.getAddAlertFlyout({
|
||||
consumer: 'discover',
|
||||
onClose,
|
||||
canChangeTrigger: false,
|
||||
ruleTypeId: ALERT_TYPE_ID,
|
||||
initialValues: {
|
||||
params: getParams(),
|
||||
},
|
||||
});
|
||||
}, [getParams, onClose, triggersActionsUi, alertFlyoutVisible]);
|
||||
|
||||
const hasTimeFieldName = dataView.timeFieldName;
|
||||
let createSearchThresholdRuleLink = (
|
||||
<EuiLink
|
||||
data-test-subj="discoverCreateAlertButton"
|
||||
onClick={() => setAlertFlyoutVisibility(true)}
|
||||
disabled={!hasTimeFieldName}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="discover.alerts.createSearchThreshold"
|
||||
defaultMessage="Create search threshold rule"
|
||||
/>
|
||||
</EuiLink>
|
||||
);
|
||||
|
||||
if (!hasTimeFieldName) {
|
||||
const toolTipContent = (
|
||||
<FormattedMessage
|
||||
id="discover.alerts.missedTimeFieldToolTip"
|
||||
defaultMessage="Data view does not have a time field."
|
||||
/>
|
||||
);
|
||||
createSearchThresholdRuleLink = (
|
||||
<EuiToolTip position="top" content={toolTipContent}>
|
||||
{createSearchThresholdRuleLink}
|
||||
</EuiToolTip>
|
||||
);
|
||||
}
|
||||
|
||||
const panels = [
|
||||
{
|
||||
id: 'mainPanel',
|
||||
name: 'Alerting',
|
||||
items: [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
{SearchThresholdAlertFlyout}
|
||||
{createSearchThresholdRuleLink}
|
||||
</>
|
||||
),
|
||||
icon: 'bell',
|
||||
disabled: !hasTimeFieldName,
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<EuiLink
|
||||
color="text"
|
||||
href={services?.application?.getUrlForApp(
|
||||
'management/insightsAndAlerting/triggersActions/alerts'
|
||||
)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="discover.alerts.manageRulesAndConnectors"
|
||||
defaultMessage="Manage rules and connectors"
|
||||
/>
|
||||
</EuiLink>
|
||||
),
|
||||
icon: 'tableOfContents',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{SearchThresholdAlertFlyout}
|
||||
<EuiWrappingPopover
|
||||
ownFocus
|
||||
button={anchorElement}
|
||||
closePopover={onClose}
|
||||
isOpen={!alertFlyoutVisible}
|
||||
>
|
||||
<EuiContextMenu initialPanelId="mainPanel" panels={panels} />
|
||||
</EuiWrappingPopover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function closeAlertsPopover() {
|
||||
ReactDOM.unmountComponentAtNode(container);
|
||||
document.body.removeChild(container);
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
export function openAlertsPopover({
|
||||
I18nContext,
|
||||
anchorElement,
|
||||
searchSource,
|
||||
services,
|
||||
}: {
|
||||
I18nContext: I18nStart['Context'];
|
||||
anchorElement: HTMLElement;
|
||||
searchSource: ISearchSource;
|
||||
services: DiscoverServices;
|
||||
}) {
|
||||
if (isOpen) {
|
||||
closeAlertsPopover();
|
||||
return;
|
||||
}
|
||||
|
||||
isOpen = true;
|
||||
document.body.appendChild(container);
|
||||
|
||||
const element = (
|
||||
<I18nContext>
|
||||
<KibanaContextProvider services={services}>
|
||||
<AlertsPopover
|
||||
onClose={closeAlertsPopover}
|
||||
anchorElement={anchorElement}
|
||||
searchSource={searchSource}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
</I18nContext>
|
||||
);
|
||||
ReactDOM.render(element, container);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { ViewAlertRoute } from './view_alert_route';
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* 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 { useEffect, useMemo } from 'react';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import { sha256 } from 'js-sha256';
|
||||
import type { Alert } from '../../../../../../x-pack/plugins/alerting/common';
|
||||
import { getTime, IndexPattern } from '../../../../data/common';
|
||||
import type { Filter } from '../../../../data/public';
|
||||
import { DiscoverAppLocatorParams } from '../../locator';
|
||||
import { useDiscoverServices } from '../../utils/use_discover_services';
|
||||
import { getAlertUtils, QueryParams, SearchThresholdAlertParams } from './view_alert_utils';
|
||||
|
||||
type NonNullableEntry<T> = { [K in keyof T]: NonNullable<T[keyof T]> };
|
||||
|
||||
const getCurrentChecksum = (params: SearchThresholdAlertParams) =>
|
||||
sha256.create().update(JSON.stringify(params)).hex();
|
||||
|
||||
const isActualAlert = (queryParams: QueryParams): queryParams is NonNullableEntry<QueryParams> => {
|
||||
return Boolean(queryParams.from && queryParams.to && queryParams.checksum);
|
||||
};
|
||||
|
||||
const buildTimeRangeFilter = (
|
||||
dataView: IndexPattern,
|
||||
fetchedAlert: Alert<SearchThresholdAlertParams>,
|
||||
timeFieldName: string
|
||||
) => {
|
||||
const filter = getTime(dataView, {
|
||||
from: `now-${fetchedAlert.params.timeWindowSize}${fetchedAlert.params.timeWindowUnit}`,
|
||||
to: 'now',
|
||||
});
|
||||
return {
|
||||
from: filter?.query.range[timeFieldName].gte,
|
||||
to: filter?.query.range[timeFieldName].lte,
|
||||
};
|
||||
};
|
||||
|
||||
const DISCOVER_MAIN_ROUTE = '/';
|
||||
|
||||
export function ViewAlertRoute() {
|
||||
const { core, data, locator, toastNotifications } = useDiscoverServices();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const history = useHistory();
|
||||
const { search } = useLocation();
|
||||
|
||||
const query = useMemo(() => new URLSearchParams(search), [search]);
|
||||
|
||||
const queryParams: QueryParams = useMemo(
|
||||
() => ({
|
||||
from: query.get('from'),
|
||||
to: query.get('to'),
|
||||
checksum: query.get('checksum'),
|
||||
}),
|
||||
[query]
|
||||
);
|
||||
|
||||
const openActualAlert = useMemo(() => isActualAlert(queryParams), [queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
fetchAlert,
|
||||
fetchSearchSource,
|
||||
displayRuleChangedWarn,
|
||||
displayPossibleDocsDiffInfoAlert,
|
||||
showDataViewFetchError,
|
||||
} = getAlertUtils(toastNotifications, core, data);
|
||||
|
||||
const navigateToResults = async () => {
|
||||
const fetchedAlert = await fetchAlert(id);
|
||||
if (!fetchedAlert) {
|
||||
history.push(DISCOVER_MAIN_ROUTE);
|
||||
return;
|
||||
}
|
||||
|
||||
const calculatedChecksum = getCurrentChecksum(fetchedAlert.params);
|
||||
if (openActualAlert && calculatedChecksum !== queryParams.checksum) {
|
||||
displayRuleChangedWarn();
|
||||
} else if (openActualAlert && calculatedChecksum === queryParams.checksum) {
|
||||
displayPossibleDocsDiffInfoAlert();
|
||||
}
|
||||
|
||||
const fetchedSearchSource = await fetchSearchSource(fetchedAlert);
|
||||
if (!fetchedSearchSource) {
|
||||
history.push(DISCOVER_MAIN_ROUTE);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataView = fetchedSearchSource.getField('index');
|
||||
const timeFieldName = dataView?.timeFieldName;
|
||||
if (!dataView || !timeFieldName) {
|
||||
showDataViewFetchError(fetchedAlert.id);
|
||||
history.push(DISCOVER_MAIN_ROUTE);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeRange = openActualAlert
|
||||
? { from: queryParams.from, to: queryParams.to }
|
||||
: buildTimeRangeFilter(dataView, fetchedAlert, timeFieldName);
|
||||
const state: DiscoverAppLocatorParams = {
|
||||
query: fetchedSearchSource.getField('query') || data.query.queryString.getDefaultQuery(),
|
||||
indexPatternId: dataView.id,
|
||||
timeRange,
|
||||
};
|
||||
|
||||
const filters = fetchedSearchSource.getField('filter');
|
||||
if (filters) {
|
||||
state.filters = filters as Filter[];
|
||||
}
|
||||
|
||||
await locator.navigate(state);
|
||||
};
|
||||
|
||||
navigateToResults();
|
||||
}, [
|
||||
toastNotifications,
|
||||
data.query.queryString,
|
||||
data.search.searchSource,
|
||||
core.http,
|
||||
locator,
|
||||
id,
|
||||
queryParams,
|
||||
history,
|
||||
openActualAlert,
|
||||
core,
|
||||
data,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { CoreStart, ToastsStart } from 'kibana/public';
|
||||
import type { Alert } from '../../../../../../x-pack/plugins/alerting/common';
|
||||
import type { AlertTypeParams } from '../../../../../../x-pack/plugins/alerting/common';
|
||||
import { SerializedSearchSourceFields } from '../../../../data/common';
|
||||
import type { DataPublicPluginStart } from '../../../../data/public';
|
||||
import { MarkdownSimple, toMountPoint } from '../../../../kibana_react/public';
|
||||
|
||||
export interface SearchThresholdAlertParams extends AlertTypeParams {
|
||||
searchConfiguration: SerializedSearchSourceFields;
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
checksum: string | null;
|
||||
}
|
||||
|
||||
const LEGACY_BASE_ALERT_API_PATH = '/api/alerts';
|
||||
|
||||
export const getAlertUtils = (
|
||||
toastNotifications: ToastsStart,
|
||||
core: CoreStart,
|
||||
data: DataPublicPluginStart
|
||||
) => {
|
||||
const showDataViewFetchError = (alertId: string) => {
|
||||
const errorTitle = i18n.translate('discover.viewAlert.dataViewErrorTitle', {
|
||||
defaultMessage: 'Error fetching data view',
|
||||
});
|
||||
toastNotifications.addDanger({
|
||||
title: errorTitle,
|
||||
text: toMountPoint(
|
||||
<MarkdownSimple>
|
||||
{new Error(`Data view failure of the alert rule with id ${alertId}.`).message}
|
||||
</MarkdownSimple>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const displayRuleChangedWarn = () => {
|
||||
const warnTitle = i18n.translate('discover.viewAlert.alertRuleChangedWarnTitle', {
|
||||
defaultMessage: 'Alert rule has changed',
|
||||
});
|
||||
const warnDescription = i18n.translate('discover.viewAlert.alertRuleChangedWarnDescription', {
|
||||
defaultMessage: `The displayed documents might not match the documents that triggered the alert
|
||||
because the rule configuration changed.`,
|
||||
});
|
||||
|
||||
toastNotifications.addWarning({
|
||||
title: warnTitle,
|
||||
text: toMountPoint(<MarkdownSimple>{warnDescription}</MarkdownSimple>),
|
||||
});
|
||||
};
|
||||
|
||||
const displayPossibleDocsDiffInfoAlert = () => {
|
||||
const infoTitle = i18n.translate('discover.viewAlert.documentsMayVaryInfoTitle', {
|
||||
defaultMessage: 'Displayed documents may vary',
|
||||
});
|
||||
const infoDescription = i18n.translate('discover.viewAlert.documentsMayVaryInfoDescription', {
|
||||
defaultMessage: `The displayed documents might differ from the documents that triggered the alert.
|
||||
Some documents might have been added or deleted.`,
|
||||
});
|
||||
|
||||
toastNotifications.addInfo({
|
||||
title: infoTitle,
|
||||
text: toMountPoint(<MarkdownSimple>{infoDescription}</MarkdownSimple>),
|
||||
});
|
||||
};
|
||||
|
||||
const fetchAlert = async (id: string) => {
|
||||
try {
|
||||
return await core.http.get<Alert<SearchThresholdAlertParams>>(
|
||||
`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}`
|
||||
);
|
||||
} catch (error) {
|
||||
const errorTitle = i18n.translate('discover.viewAlert.alertRuleFetchErrorTitle', {
|
||||
defaultMessage: 'Error fetching alert rule',
|
||||
});
|
||||
toastNotifications.addDanger({
|
||||
title: errorTitle,
|
||||
text: toMountPoint(<MarkdownSimple>{error.message}</MarkdownSimple>),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSearchSource = async (fetchedAlert: Alert<SearchThresholdAlertParams>) => {
|
||||
try {
|
||||
return await data.search.searchSource.create(fetchedAlert.params.searchConfiguration);
|
||||
} catch (error) {
|
||||
const errorTitle = i18n.translate('discover.viewAlert.searchSourceErrorTitle', {
|
||||
defaultMessage: 'Error fetching search source',
|
||||
});
|
||||
toastNotifications.addDanger({
|
||||
title: errorTitle,
|
||||
text: toMountPoint(<MarkdownSimple>{error.message}</MarkdownSimple>),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
displayRuleChangedWarn,
|
||||
displayPossibleDocsDiffInfoAlert,
|
||||
showDataViewFetchError,
|
||||
fetchAlert,
|
||||
fetchSearchSource,
|
||||
};
|
||||
};
|
|
@ -18,6 +18,8 @@ import {
|
|||
IUiSettingsClient,
|
||||
PluginInitializerContext,
|
||||
HttpStart,
|
||||
NotificationsStart,
|
||||
ApplicationStart,
|
||||
} from 'kibana/public';
|
||||
import {
|
||||
FilterManager,
|
||||
|
@ -38,15 +40,18 @@ import { NavigationPublicPluginStart } from '../../navigation/public';
|
|||
import { IndexPatternFieldEditorStart } from '../../data_view_field_editor/public';
|
||||
import { FieldFormatsStart } from '../../field_formats/public';
|
||||
import { EmbeddableStart } from '../../embeddable/public';
|
||||
import { DiscoverAppLocator } from './locator';
|
||||
|
||||
import type { SpacesApi } from '../../../../x-pack/plugins/spaces/public';
|
||||
import { DataViewEditorStart } from '../../../plugins/data_view_editor/public';
|
||||
import type { TriggersAndActionsUIPublicPluginStart } from '../../../../x-pack/plugins/triggers_actions_ui/public';
|
||||
|
||||
export interface HistoryLocationState {
|
||||
referrer: string;
|
||||
}
|
||||
|
||||
export interface DiscoverServices {
|
||||
application: ApplicationStart;
|
||||
addBasePath: (path: string) => string;
|
||||
capabilities: Capabilities;
|
||||
chrome: ChromeStart;
|
||||
|
@ -66,6 +71,7 @@ export interface DiscoverServices {
|
|||
urlForwarding: UrlForwardingStart;
|
||||
timefilter: TimefilterContract;
|
||||
toastNotifications: ToastsStart;
|
||||
notifications: NotificationsStart;
|
||||
uiSettings: IUiSettingsClient;
|
||||
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
|
||||
dataViewFieldEditor: IndexPatternFieldEditorStart;
|
||||
|
@ -73,17 +79,21 @@ export interface DiscoverServices {
|
|||
http: HttpStart;
|
||||
storage: Storage;
|
||||
spaces?: SpacesApi;
|
||||
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
|
||||
locator: DiscoverAppLocator;
|
||||
}
|
||||
|
||||
export const buildServices = memoize(function (
|
||||
core: CoreStart,
|
||||
plugins: DiscoverStartPlugins,
|
||||
context: PluginInitializerContext
|
||||
context: PluginInitializerContext,
|
||||
locator: DiscoverAppLocator
|
||||
): DiscoverServices {
|
||||
const { usageCollection } = plugins;
|
||||
const storage = new Storage(localStorage);
|
||||
|
||||
return {
|
||||
application: core.application,
|
||||
addBasePath: core.http.basePath.prepend,
|
||||
capabilities: core.application.capabilities,
|
||||
chrome: core.chrome,
|
||||
|
@ -105,6 +115,7 @@ export const buildServices = memoize(function (
|
|||
urlForwarding: plugins.urlForwarding,
|
||||
timefilter: plugins.data.query.timefilter.timefilter,
|
||||
toastNotifications: core.notifications.toasts,
|
||||
notifications: core.notifications,
|
||||
uiSettings: core.uiSettings,
|
||||
storage,
|
||||
trackUiMetric: usageCollection?.reportUiCounter.bind(usageCollection, 'discover'),
|
||||
|
@ -112,5 +123,7 @@ export const buildServices = memoize(function (
|
|||
http: core.http,
|
||||
spaces: plugins.spaces,
|
||||
dataViewEditor: plugins.dataViewEditor,
|
||||
triggersActionsUi: plugins.triggersActionsUi,
|
||||
locator,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -53,6 +53,7 @@ import { injectTruncateStyles } from './utils/truncate_styles';
|
|||
import { DOC_TABLE_LEGACY, TRUNCATE_MAX_HEIGHT } from '../common';
|
||||
import { DataViewEditorStart } from '../../../plugins/data_view_editor/public';
|
||||
import { useDiscoverServices } from './utils/use_discover_services';
|
||||
import type { TriggersAndActionsUIPublicPluginStart } from '../../../../x-pack/plugins/triggers_actions_ui/public';
|
||||
import { initializeKbnUrlTracking } from './utils/initialize_kbn_url_tracking';
|
||||
|
||||
const DocViewerLegacyTable = React.lazy(
|
||||
|
@ -170,6 +171,7 @@ export interface DiscoverStartPlugins {
|
|||
usageCollection?: UsageCollectionSetup;
|
||||
dataViewFieldEditor: IndexPatternFieldEditorStart;
|
||||
spaces?: SpacesPluginStart;
|
||||
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -274,7 +276,12 @@ export class DiscoverPlugin
|
|||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
|
||||
const services = buildServices(coreStart, discoverStartPlugins, this.initializerContext);
|
||||
const services = buildServices(
|
||||
coreStart,
|
||||
discoverStartPlugins,
|
||||
this.initializerContext,
|
||||
this.locator!
|
||||
);
|
||||
|
||||
// make sure the index pattern list is up to date
|
||||
await discoverStartPlugins.data.indexPatterns.clearCache();
|
||||
|
@ -364,7 +371,7 @@ export class DiscoverPlugin
|
|||
|
||||
const getDiscoverServices = async () => {
|
||||
const [coreStart, discoverStartPlugins] = await core.getStartServices();
|
||||
return buildServices(coreStart, discoverStartPlugins, this.initializerContext);
|
||||
return buildServices(coreStart, discoverStartPlugins, this.initializerContext, this.locator!);
|
||||
};
|
||||
|
||||
const factory = new SearchEmbeddableFactory(getStartServices, getDiscoverServices);
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
{ "path": "../field_formats/tsconfig.json" },
|
||||
{ "path": "../data_views/tsconfig.json" },
|
||||
{ "path": "../../../x-pack/plugins/spaces/tsconfig.json" },
|
||||
{ "path": "../data_view_editor/tsconfig.json" }
|
||||
{ "path": "../data_view_editor/tsconfig.json" },
|
||||
{ "path": "../../../x-pack/plugins/triggers_actions_ui/tsconfig.json" }
|
||||
]
|
||||
}
|
||||
|
|
|
@ -16,13 +16,14 @@ export class IndexPatternsService extends FtrService {
|
|||
* Create a new index pattern
|
||||
*/
|
||||
async create(
|
||||
indexPattern: { title: string },
|
||||
{ override = false }: { override: boolean } = { override: false }
|
||||
indexPattern: { title: string; timeFieldName?: string },
|
||||
{ override = false }: { override: boolean },
|
||||
spaceId = ''
|
||||
): Promise<DataViewSpec> {
|
||||
const response = await this.kibanaServer.request<{
|
||||
index_pattern: DataViewSpec;
|
||||
}>({
|
||||
path: '/api/index_patterns/index_pattern',
|
||||
path: `${spaceId}/api/index_patterns/index_pattern`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
override,
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"configPath": ["xpack", "alerting"],
|
||||
"requiredPlugins": [
|
||||
"actions",
|
||||
"data",
|
||||
"encryptedSavedObjects",
|
||||
"eventLog",
|
||||
"features",
|
||||
|
|
|
@ -12,7 +12,9 @@ import {
|
|||
elasticsearchServiceMock,
|
||||
savedObjectsClientMock,
|
||||
uiSettingsServiceMock,
|
||||
httpServerMock,
|
||||
} from '../../../../src/core/server/mocks';
|
||||
import { dataPluginMock } from '../../../../src/plugins/data/server/mocks';
|
||||
import { AlertInstanceContext, AlertInstanceState } from './types';
|
||||
|
||||
export { rulesClientMock };
|
||||
|
@ -111,6 +113,11 @@ const createAlertServicesMock = <
|
|||
shouldWriteAlerts: () => true,
|
||||
shouldStopExecution: () => true,
|
||||
search: createAbortableSearchServiceMock(),
|
||||
searchSourceClient: Promise.resolve(
|
||||
dataPluginMock
|
||||
.createStartContract()
|
||||
.search.searchSource.asScoped(httpServerMock.createKibanaRequest())
|
||||
),
|
||||
};
|
||||
};
|
||||
export type AlertServicesMock = ReturnType<typeof createAlertServicesMock>;
|
||||
|
|
|
@ -19,6 +19,7 @@ import { AlertingConfig } from './config';
|
|||
import { RuleType } from './types';
|
||||
import { eventLogMock } from '../../event_log/server/mocks';
|
||||
import { actionsMock } from '../../actions/server/mocks';
|
||||
import { dataPluginMock } from '../../../../src/plugins/data/server/mocks';
|
||||
import { monitoringCollectionMock } from '../../monitoring_collection/server/mocks';
|
||||
|
||||
const generateAlertingConfig = (): AlertingConfig => ({
|
||||
|
@ -276,6 +277,7 @@ describe('Alerting Plugin', () => {
|
|||
licensing: licensingMock.createStart(),
|
||||
eventLog: eventLogMock.createStart(),
|
||||
taskManager: taskManagerMock.createStart(),
|
||||
data: dataPluginMock.createStartContract(),
|
||||
});
|
||||
|
||||
expect(encryptedSavedObjectsSetup.canEncrypt).toEqual(false);
|
||||
|
@ -313,6 +315,7 @@ describe('Alerting Plugin', () => {
|
|||
licensing: licensingMock.createStart(),
|
||||
eventLog: eventLogMock.createStart(),
|
||||
taskManager: taskManagerMock.createStart(),
|
||||
data: dataPluginMock.createStartContract(),
|
||||
});
|
||||
|
||||
const fakeRequest = {
|
||||
|
@ -361,6 +364,7 @@ describe('Alerting Plugin', () => {
|
|||
licensing: licensingMock.createStart(),
|
||||
eventLog: eventLogMock.createStart(),
|
||||
taskManager: taskManagerMock.createStart(),
|
||||
data: dataPluginMock.createStartContract(),
|
||||
});
|
||||
|
||||
const fakeRequest = {
|
||||
|
|
|
@ -63,6 +63,7 @@ import { getHealth } from './health/get_health';
|
|||
import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory';
|
||||
import { AlertingAuthorization } from './authorization';
|
||||
import { getSecurityHealth, SecurityHealth } from './lib/get_security_health';
|
||||
import { PluginStart as DataPluginStart } from '../../../../src/plugins/data/server';
|
||||
import { MonitoringCollectionSetup } from '../../monitoring_collection/server';
|
||||
import { registerNodeCollector, registerClusterCollector, InMemoryMetrics } from './monitoring';
|
||||
import { getExecutionConfigForRuleType } from './lib/get_rules_config';
|
||||
|
@ -139,6 +140,7 @@ export interface AlertingPluginsStart {
|
|||
licensing: LicensingPluginStart;
|
||||
spaces?: SpacesPluginStart;
|
||||
security?: SecurityPluginStart;
|
||||
data: DataPluginStart;
|
||||
}
|
||||
|
||||
export class AlertingPlugin {
|
||||
|
@ -407,6 +409,7 @@ export class AlertingPlugin {
|
|||
|
||||
taskRunnerFactory.initialize({
|
||||
logger,
|
||||
data: plugins.data,
|
||||
savedObjects: core.savedObjects,
|
||||
uiSettings: core.uiSettings,
|
||||
elasticsearch: core.elasticsearch,
|
||||
|
|
|
@ -70,6 +70,7 @@ import {
|
|||
import { EVENT_LOG_ACTIONS } from '../plugin';
|
||||
import { IN_MEMORY_METRICS } from '../monitoring';
|
||||
import { translations } from '../constants/translations';
|
||||
import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
|
||||
|
@ -101,6 +102,7 @@ describe('Task Runner', () => {
|
|||
const ruleTypeRegistry = ruleTypeRegistryMock.create();
|
||||
const savedObjectsService = savedObjectsServiceMock.createInternalStartContract();
|
||||
const elasticsearchService = elasticsearchServiceMock.createInternalStart();
|
||||
const dataPlugin = dataPluginMock.createStartContract();
|
||||
const uiSettingsService = uiSettingsServiceMock.createStartContract();
|
||||
const inMemoryMetrics = inMemoryMetricsMock.create();
|
||||
|
||||
|
@ -113,6 +115,7 @@ describe('Task Runner', () => {
|
|||
type EnqueueFunction = (options: ExecuteOptions) => Promise<void | RunNowResult>;
|
||||
|
||||
const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = {
|
||||
data: dataPlugin,
|
||||
savedObjects: savedObjectsService,
|
||||
uiSettings: uiSettingsService,
|
||||
elasticsearch: elasticsearchService,
|
||||
|
|
|
@ -391,6 +391,7 @@ export class TaskRunner<
|
|||
savedObjectsClient,
|
||||
uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient),
|
||||
scopedClusterClient: wrappedScopedClusterClient.client(),
|
||||
searchSourceClient: this.context.data.search.searchSource.asScoped(fakeRequest),
|
||||
alertFactory: createAlertFactory<
|
||||
InstanceState,
|
||||
InstanceContext,
|
||||
|
|
|
@ -35,6 +35,7 @@ import { IEventLogger } from '../../../event_log/server';
|
|||
import { Alert, RecoveredActionGroup } from '../../common';
|
||||
import { UntypedNormalizedRuleType } from '../rule_type_registry';
|
||||
import { ruleTypeRegistryMock } from '../rule_type_registry.mock';
|
||||
import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks';
|
||||
import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
|
@ -104,6 +105,7 @@ describe('Task Runner Cancel', () => {
|
|||
const savedObjectsService = savedObjectsServiceMock.createInternalStartContract();
|
||||
const elasticsearchService = elasticsearchServiceMock.createInternalStart();
|
||||
const uiSettingsService = uiSettingsServiceMock.createStartContract();
|
||||
const dataPlugin = dataPluginMock.createStartContract();
|
||||
const inMemoryMetrics = inMemoryMetricsMock.create();
|
||||
|
||||
type TaskRunnerFactoryInitializerParamsType = jest.Mocked<TaskRunnerContext> & {
|
||||
|
@ -113,6 +115,7 @@ describe('Task Runner Cancel', () => {
|
|||
};
|
||||
|
||||
const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = {
|
||||
data: dataPlugin,
|
||||
savedObjects: savedObjectsService,
|
||||
uiSettings: uiSettingsService,
|
||||
elasticsearch: elasticsearchService,
|
||||
|
|
|
@ -24,6 +24,7 @@ import { eventLoggerMock } from '../../../event_log/server/event_logger.mock';
|
|||
import { UntypedNormalizedRuleType } from '../rule_type_registry';
|
||||
import { ruleTypeRegistryMock } from '../rule_type_registry.mock';
|
||||
import { executionContextServiceMock } from '../../../../../src/core/server/mocks';
|
||||
import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks';
|
||||
import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
|
||||
|
||||
const inMemoryMetrics = inMemoryMetricsMock.create();
|
||||
|
@ -33,6 +34,7 @@ const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test');
|
|||
const savedObjectsService = savedObjectsServiceMock.createInternalStartContract();
|
||||
const uiSettingsService = uiSettingsServiceMock.createStartContract();
|
||||
const elasticsearchService = elasticsearchServiceMock.createInternalStart();
|
||||
const dataPlugin = dataPluginMock.createStartContract();
|
||||
const ruleType: UntypedNormalizedRuleType = {
|
||||
id: 'test',
|
||||
name: 'My test alert',
|
||||
|
@ -80,6 +82,7 @@ describe('Task Runner Factory', () => {
|
|||
const rulesClient = rulesClientMock.create();
|
||||
|
||||
const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> = {
|
||||
data: dataPlugin,
|
||||
savedObjects: savedObjectsService,
|
||||
uiSettings: uiSettingsService,
|
||||
elasticsearch: elasticsearchService,
|
||||
|
|
|
@ -32,10 +32,12 @@ import { TaskRunner } from './task_runner';
|
|||
import { IEventLogger } from '../../../event_log/server';
|
||||
import { RulesClient } from '../rules_client';
|
||||
import { NormalizedRuleType } from '../rule_type_registry';
|
||||
import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/server';
|
||||
import { InMemoryMetrics } from '../monitoring';
|
||||
|
||||
export interface TaskRunnerContext {
|
||||
logger: Logger;
|
||||
data: DataPluginStart;
|
||||
savedObjects: SavedObjectsServiceStart;
|
||||
uiSettings: UiSettingsServiceStart;
|
||||
elasticsearch: ElasticsearchServiceStart;
|
||||
|
|
|
@ -42,6 +42,7 @@ import {
|
|||
AlertExecutionStatusWarningReasons,
|
||||
} from '../common';
|
||||
import { LicenseType } from '../../licensing/server';
|
||||
import { ISearchStartSearchSource } from '../../../../src/plugins/data/common';
|
||||
import { RuleTypeConfig } from './config';
|
||||
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
|
||||
export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined;
|
||||
|
@ -73,6 +74,7 @@ export interface AlertServices<
|
|||
InstanceContext extends AlertInstanceContext = AlertInstanceContext,
|
||||
ActionGroupIds extends string = never
|
||||
> {
|
||||
searchSourceClient: Promise<ISearchStartSearchSource>;
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
uiSettingsClient: IUiSettingsClient;
|
||||
scopedClusterClient: IScopedClusterClient;
|
||||
|
|
|
@ -660,11 +660,15 @@ Array [
|
|||
"privilege": Object {
|
||||
"alerting": Object {
|
||||
"alert": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
".es-query",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"rule": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
".es-query",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
},
|
||||
|
@ -711,6 +715,18 @@ Array [
|
|||
},
|
||||
Object {
|
||||
"privilege": Object {
|
||||
"alerting": Object {
|
||||
"alert": Object {
|
||||
"all": Array [
|
||||
".es-query",
|
||||
],
|
||||
},
|
||||
"rule": Object {
|
||||
"all": Array [
|
||||
".es-query",
|
||||
],
|
||||
},
|
||||
},
|
||||
"app": Array [
|
||||
"discover",
|
||||
"kibana",
|
||||
|
@ -1140,11 +1156,15 @@ Array [
|
|||
"privilege": Object {
|
||||
"alerting": Object {
|
||||
"alert": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
".es-query",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"rule": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
".es-query",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
},
|
||||
|
@ -1191,6 +1211,18 @@ Array [
|
|||
},
|
||||
Object {
|
||||
"privilege": Object {
|
||||
"alerting": Object {
|
||||
"alert": Object {
|
||||
"all": Array [
|
||||
".es-query",
|
||||
],
|
||||
},
|
||||
"rule": Object {
|
||||
"all": Array [
|
||||
".es-query",
|
||||
],
|
||||
},
|
||||
},
|
||||
"app": Array [
|
||||
"discover",
|
||||
"kibana",
|
||||
|
|
|
@ -32,6 +32,7 @@ export const buildOSSFeatures = ({
|
|||
category: DEFAULT_APP_CATEGORIES.kibana,
|
||||
app: ['discover', 'kibana'],
|
||||
catalogue: ['discover'],
|
||||
alerting: ['.es-query'],
|
||||
privileges: {
|
||||
all: {
|
||||
app: ['discover', 'kibana'],
|
||||
|
@ -42,6 +43,14 @@ export const buildOSSFeatures = ({
|
|||
read: ['index-pattern'],
|
||||
},
|
||||
ui: ['show', 'save', 'saveQuery'],
|
||||
alerting: {
|
||||
rule: {
|
||||
all: ['.es-query'],
|
||||
},
|
||||
alert: {
|
||||
all: ['.es-query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
read: {
|
||||
app: ['discover', 'kibana'],
|
||||
|
@ -51,6 +60,14 @@ export const buildOSSFeatures = ({
|
|||
read: ['index-pattern', 'search', 'query'],
|
||||
},
|
||||
ui: ['show'],
|
||||
alerting: {
|
||||
rule: {
|
||||
all: ['.es-query'],
|
||||
},
|
||||
alert: {
|
||||
all: ['.es-query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
|
|
|
@ -18,6 +18,7 @@ import { castArray, omit } from 'lodash';
|
|||
import { RuleDataClient } from '../rule_data_client';
|
||||
import { createRuleDataClientMock } from '../rule_data_client/rule_data_client.mock';
|
||||
import { createLifecycleRuleTypeFactory } from './create_lifecycle_rule_type_factory';
|
||||
import { ISearchStartSearchSource } from '../../../../../src/plugins/data/common';
|
||||
|
||||
type RuleTestHelpers = ReturnType<typeof createRule>;
|
||||
|
||||
|
@ -117,6 +118,7 @@ function createRule(shouldWriteAlerts: boolean = true) {
|
|||
shouldWriteAlerts: () => shouldWriteAlerts,
|
||||
shouldStopExecution: () => false,
|
||||
search: {} as any,
|
||||
searchSourceClient: Promise.resolve({} as ISearchStartSearchSource),
|
||||
},
|
||||
spaceId: 'spaceId',
|
||||
state,
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import {
|
||||
elasticsearchServiceMock,
|
||||
savedObjectsClientMock,
|
||||
httpServerMock,
|
||||
uiSettingsServiceMock,
|
||||
} from '../../../../../src/core/server/mocks';
|
||||
import {
|
||||
|
@ -17,6 +18,7 @@ import {
|
|||
AlertTypeState,
|
||||
} from '../../../alerting/server';
|
||||
import { alertsMock } from '../../../alerting/server/mocks';
|
||||
import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks';
|
||||
|
||||
export const createDefaultAlertExecutorOptions = <
|
||||
Params extends AlertTypeParams = never,
|
||||
|
@ -74,6 +76,11 @@ export const createDefaultAlertExecutorOptions = <
|
|||
scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(),
|
||||
shouldWriteAlerts: () => shouldWriteAlerts,
|
||||
shouldStopExecution: () => false,
|
||||
searchSourceClient: Promise.resolve(
|
||||
dataPluginMock
|
||||
.createStartContract()
|
||||
.search.searchSource.asScoped(httpServerMock.createKibanaRequest())
|
||||
),
|
||||
},
|
||||
state,
|
||||
updatedBy: null,
|
||||
|
|
|
@ -7,7 +7,9 @@
|
|||
import moment from 'moment';
|
||||
import uuid from 'uuid';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
import type { StartServicesAccessor } from 'kibana/server';
|
||||
import { IRuleDataClient } from '../../../../../../rule_registry/server';
|
||||
import type { StartPlugins } from '../../../../plugin';
|
||||
import { buildSiemResponse } from '../utils';
|
||||
import { convertCreateAPIToInternalSchema } from '../../schemas/rule_converters';
|
||||
import { RuleParams } from '../../schemas/rule_schemas';
|
||||
|
@ -59,7 +61,8 @@ export const previewRulesRoute = async (
|
|||
security: SetupPlugins['security'],
|
||||
ruleOptions: CreateRuleOptions,
|
||||
securityRuleTypeOptions: CreateSecurityRuleTypeWrapperProps,
|
||||
previewRuleDataClient: IRuleDataClient
|
||||
previewRuleDataClient: IRuleDataClient,
|
||||
getStartServices: StartServicesAccessor<StartPlugins>
|
||||
) => {
|
||||
router.post(
|
||||
{
|
||||
|
@ -78,6 +81,8 @@ export const previewRulesRoute = async (
|
|||
return siemResponse.error({ statusCode: 400, body: validationErrors });
|
||||
}
|
||||
try {
|
||||
const [, { data }] = await getStartServices();
|
||||
const searchSourceClient = data.search.searchSource.asScoped(request);
|
||||
const savedObjectsClient = context.core.savedObjects.client;
|
||||
const siemClient = context.securitySolution.getAppClient();
|
||||
|
||||
|
@ -203,6 +208,7 @@ export const previewRulesRoute = async (
|
|||
abortController,
|
||||
scopedClusterClient: context.core.elasticsearch.client,
|
||||
}),
|
||||
searchSourceClient,
|
||||
uiSettingsClient: context.core.uiSettings.client,
|
||||
},
|
||||
spaceId,
|
||||
|
|
|
@ -103,7 +103,8 @@ export const initRoutes = (
|
|||
security,
|
||||
ruleOptions,
|
||||
securityRuleTypeOptions,
|
||||
previewRuleDataClient
|
||||
previewRuleDataClient,
|
||||
getStartServices
|
||||
);
|
||||
|
||||
// Once we no longer have the legacy notifications system/"side car actions" this should be removed.
|
||||
|
|
15
x-pack/plugins/stack_alerts/common/comparator_types.ts
Normal file
15
x-pack/plugins/stack_alerts/common/comparator_types.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export enum Comparator {
|
||||
GT = '>',
|
||||
LT = '<',
|
||||
GT_OR_EQ = '>=',
|
||||
LT_OR_EQ = '<=',
|
||||
BETWEEN = 'between',
|
||||
NOT_BETWEEN = 'notBetween',
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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 { COMPARATORS } from '../../../../triggers_actions_ui/public';
|
||||
|
||||
export const DEFAULT_VALUES = {
|
||||
THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN,
|
||||
QUERY: `{
|
||||
"query":{
|
||||
"match_all" : {}
|
||||
}
|
||||
}`,
|
||||
SIZE: 100,
|
||||
TIME_WINDOW_SIZE: 5,
|
||||
TIME_WINDOW_UNIT: 'm',
|
||||
THRESHOLD: [1000],
|
||||
};
|
|
@ -10,7 +10,6 @@ import 'brace';
|
|||
import { of } from 'rxjs';
|
||||
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import EsQueryAlertTypeExpression from './expression';
|
||||
import { dataPluginMock } from 'src/plugins/data/public/mocks';
|
||||
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
|
||||
import {
|
||||
|
@ -18,11 +17,12 @@ import {
|
|||
IKibanaSearchResponse,
|
||||
ISearchStart,
|
||||
} from 'src/plugins/data/public';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { EsQueryAlertParams } from './types';
|
||||
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
|
||||
import { EsQueryAlertParams, SearchType } from '../types';
|
||||
import { EsQueryExpression } from './es_query_expression';
|
||||
|
||||
jest.mock('../../../../../../src/plugins/kibana_react/public');
|
||||
jest.mock('../../../../../../src/plugins/es_ui_shared/public', () => ({
|
||||
jest.mock('../../../../../../../src/plugins/kibana_react/public');
|
||||
jest.mock('../../../../../../../src/plugins/es_ui_shared/public', () => ({
|
||||
XJson: {
|
||||
useXJsonMode: jest.fn().mockReturnValue({
|
||||
convertToJson: jest.fn(),
|
||||
|
@ -42,8 +42,8 @@ jest.mock('../../../../../../src/plugins/es_ui_shared/public', () => ({
|
|||
/>
|
||||
),
|
||||
}));
|
||||
jest.mock('../../../../triggers_actions_ui/public', () => {
|
||||
const original = jest.requireActual('../../../../triggers_actions_ui/public');
|
||||
jest.mock('../../../../../triggers_actions_ui/public', () => {
|
||||
const original = jest.requireActual('../../../../../triggers_actions_ui/public');
|
||||
return {
|
||||
...original,
|
||||
getIndexPatterns: () => {
|
||||
|
@ -100,6 +100,17 @@ const createDataPluginMock = () => {
|
|||
const dataMock = createDataPluginMock();
|
||||
const chartsStartMock = chartPluginMock.createStartContract();
|
||||
|
||||
const defaultEsQueryExpressionParams: EsQueryAlertParams<SearchType.esQuery> = {
|
||||
size: 100,
|
||||
thresholdComparator: '>',
|
||||
threshold: [0],
|
||||
timeWindowSize: 15,
|
||||
timeWindowUnit: 's',
|
||||
index: ['test-index'],
|
||||
timeField: '@timestamp',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
};
|
||||
|
||||
describe('EsQueryAlertTypeExpression', () => {
|
||||
beforeAll(() => {
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
|
@ -117,20 +128,7 @@ describe('EsQueryAlertTypeExpression', () => {
|
|||
});
|
||||
});
|
||||
|
||||
function getAlertParams(overrides = {}) {
|
||||
return {
|
||||
index: ['test-index'],
|
||||
timeField: '@timestamp',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
thresholdComparator: '>',
|
||||
threshold: [0],
|
||||
timeWindowSize: 15,
|
||||
timeWindowUnit: 's',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
async function setup(alertParams: EsQueryAlertParams) {
|
||||
async function setup(alertParams: EsQueryAlertParams<SearchType.esQuery>) {
|
||||
const errors = {
|
||||
index: [],
|
||||
esQuery: [],
|
||||
|
@ -140,7 +138,7 @@ describe('EsQueryAlertTypeExpression', () => {
|
|||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<EsQueryAlertTypeExpression
|
||||
<EsQueryExpression
|
||||
ruleInterval="1m"
|
||||
ruleThrottle="1m"
|
||||
alertNotifyWhen="onThrottleInterval"
|
||||
|
@ -166,7 +164,7 @@ describe('EsQueryAlertTypeExpression', () => {
|
|||
}
|
||||
|
||||
test('should render EsQueryAlertTypeExpression with expected components', async () => {
|
||||
const wrapper = await setup(getAlertParams());
|
||||
const wrapper = await setup(defaultEsQueryExpressionParams);
|
||||
expect(wrapper.find('[data-test-subj="indexSelectPopover"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="sizeValueExpression"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="queryJsonEditor"]').exists()).toBeTruthy();
|
||||
|
@ -181,7 +179,10 @@ describe('EsQueryAlertTypeExpression', () => {
|
|||
});
|
||||
|
||||
test('should render Test Query button disabled if alert params are invalid', async () => {
|
||||
const wrapper = await setup(getAlertParams({ timeField: null }));
|
||||
const wrapper = await setup({
|
||||
...defaultEsQueryExpressionParams,
|
||||
timeField: null,
|
||||
} as unknown as EsQueryAlertParams<SearchType.esQuery>);
|
||||
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
|
||||
expect(testQueryButton.exists()).toBeTruthy();
|
||||
expect(testQueryButton.prop('disabled')).toBe(true);
|
||||
|
@ -196,7 +197,7 @@ describe('EsQueryAlertTypeExpression', () => {
|
|||
},
|
||||
});
|
||||
dataMock.search.search.mockImplementation(() => searchResponseMock$);
|
||||
const wrapper = await setup(getAlertParams());
|
||||
const wrapper = await setup(defaultEsQueryExpressionParams);
|
||||
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
|
||||
|
||||
testQueryButton.simulate('click');
|
||||
|
@ -217,7 +218,7 @@ describe('EsQueryAlertTypeExpression', () => {
|
|||
dataMock.search.search.mockImplementation(() => {
|
||||
throw new Error('What is this query');
|
||||
});
|
||||
const wrapper = await setup(getAlertParams());
|
||||
const wrapper = await setup(defaultEsQueryExpressionParams);
|
||||
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
|
||||
|
||||
testQueryButton.simulate('click');
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState, Fragment, useEffect } from 'react';
|
||||
import React, { useState, Fragment, useEffect, useCallback } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
|
@ -18,7 +18,6 @@ import {
|
|||
EuiButtonEmpty,
|
||||
EuiSpacer,
|
||||
EuiFormRow,
|
||||
EuiCallOut,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiLink,
|
||||
|
@ -27,49 +26,26 @@ import {
|
|||
import { DocLinksStart, HttpSetup } from 'kibana/public';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
|
||||
import { XJson, EuiCodeEditor } from '../../../../../../src/plugins/es_ui_shared/public';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { XJson, EuiCodeEditor } from '../../../../../../../src/plugins/es_ui_shared/public';
|
||||
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
|
||||
import {
|
||||
getFields,
|
||||
COMPARATORS,
|
||||
ThresholdExpression,
|
||||
ForLastExpression,
|
||||
ValueExpression,
|
||||
RuleTypeParamsExpressionProps,
|
||||
} from '../../../../triggers_actions_ui/public';
|
||||
import { validateExpression } from './validation';
|
||||
import { parseDuration } from '../../../../alerting/common';
|
||||
import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query';
|
||||
import { EsQueryAlertParams } from './types';
|
||||
import { IndexSelectPopover } from '../components/index_select_popover';
|
||||
ForLastExpression,
|
||||
ThresholdExpression,
|
||||
} from '../../../../../triggers_actions_ui/public';
|
||||
import { validateExpression } from '../validation';
|
||||
import { parseDuration } from '../../../../../alerting/common';
|
||||
import { buildSortedEventsQuery } from '../../../../common/build_sorted_events_query';
|
||||
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;
|
||||
}
|
||||
|
||||
const DEFAULT_VALUES = {
|
||||
THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN,
|
||||
QUERY: `{
|
||||
"query":{
|
||||
"match_all" : {}
|
||||
}
|
||||
}`,
|
||||
SIZE: 100,
|
||||
TIME_WINDOW_SIZE: 5,
|
||||
TIME_WINDOW_UNIT: 'm',
|
||||
THRESHOLD: [1000],
|
||||
};
|
||||
|
||||
const expressionFieldsWithValidation = [
|
||||
'index',
|
||||
'esQuery',
|
||||
'size',
|
||||
'timeField',
|
||||
'threshold0',
|
||||
'threshold1',
|
||||
'timeWindowSize',
|
||||
];
|
||||
|
||||
const { useXJsonMode } = XJson;
|
||||
const xJsonMode = new XJsonMode();
|
||||
|
||||
|
@ -78,9 +54,13 @@ interface KibanaDeps {
|
|||
docLinks: DocLinksStart;
|
||||
}
|
||||
|
||||
export const EsQueryAlertTypeExpression: React.FunctionComponent<
|
||||
RuleTypeParamsExpressionProps<EsQueryAlertParams>
|
||||
> = ({ ruleParams, setRuleParams, setRuleProperty, errors, data }) => {
|
||||
export const EsQueryExpression = ({
|
||||
ruleParams,
|
||||
setRuleParams,
|
||||
setRuleProperty,
|
||||
errors,
|
||||
data,
|
||||
}: RuleTypeParamsExpressionProps<EsQueryAlertParams<SearchType.esQuery>>) => {
|
||||
const {
|
||||
index,
|
||||
timeField,
|
||||
|
@ -92,16 +72,29 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent<
|
|||
timeWindowUnit,
|
||||
} = ruleParams;
|
||||
|
||||
const getDefaultParams = () => ({
|
||||
const [currentAlertParams, setCurrentAlertParams] = useState<
|
||||
EsQueryAlertParams<SearchType.esQuery>
|
||||
>({
|
||||
...ruleParams,
|
||||
esQuery: esQuery ?? DEFAULT_VALUES.QUERY,
|
||||
size: size ?? DEFAULT_VALUES.SIZE,
|
||||
timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE,
|
||||
timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT,
|
||||
threshold: threshold ?? DEFAULT_VALUES.THRESHOLD,
|
||||
thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR,
|
||||
size: size ?? DEFAULT_VALUES.SIZE,
|
||||
esQuery: esQuery ?? DEFAULT_VALUES.QUERY,
|
||||
});
|
||||
|
||||
const setParam = useCallback(
|
||||
(paramField: string, paramValue: unknown) => {
|
||||
setCurrentAlertParams((currentParams) => ({
|
||||
...currentParams,
|
||||
[paramField]: paramValue,
|
||||
}));
|
||||
setRuleParams(paramField, paramValue);
|
||||
},
|
||||
[setRuleParams]
|
||||
);
|
||||
|
||||
const { http, docLinks } = useKibana<KibanaDeps>().services;
|
||||
|
||||
const [esFields, setEsFields] = useState<
|
||||
|
@ -114,29 +107,11 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent<
|
|||
}>
|
||||
>([]);
|
||||
const { convertToJson, setXJson, xJson } = useXJsonMode(DEFAULT_VALUES.QUERY);
|
||||
const [currentAlertParams, setCurrentAlertParams] = useState<EsQueryAlertParams>(
|
||||
getDefaultParams()
|
||||
);
|
||||
const [testQueryResult, setTestQueryResult] = useState<string | null>(null);
|
||||
const [testQueryError, setTestQueryError] = useState<string | null>(null);
|
||||
|
||||
const hasExpressionErrors = !!Object.keys(errors).find(
|
||||
(errorKey) =>
|
||||
expressionFieldsWithValidation.includes(errorKey) &&
|
||||
errors[errorKey].length >= 1 &&
|
||||
ruleParams[errorKey as keyof EsQueryAlertParams] !== undefined
|
||||
);
|
||||
|
||||
const expressionErrorMessage = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.ui.alertParams.fixErrorInExpressionBelowValidationMessage',
|
||||
{
|
||||
defaultMessage: 'Expression contains errors.',
|
||||
}
|
||||
);
|
||||
|
||||
const setDefaultExpressionValues = async () => {
|
||||
setRuleProperty('params', getDefaultParams());
|
||||
|
||||
setRuleProperty('params', currentAlertParams);
|
||||
setXJson(esQuery ?? DEFAULT_VALUES.QUERY);
|
||||
|
||||
if (index && index.length > 0) {
|
||||
|
@ -144,14 +119,6 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent<
|
|||
}
|
||||
};
|
||||
|
||||
const setParam = (paramField: string, paramValue: unknown) => {
|
||||
setCurrentAlertParams({
|
||||
...currentAlertParams,
|
||||
[paramField]: paramValue,
|
||||
});
|
||||
setRuleParams(paramField, paramValue);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultExpressionValues();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
@ -216,13 +183,6 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent<
|
|||
|
||||
return (
|
||||
<Fragment>
|
||||
{hasExpressionErrors ? (
|
||||
<Fragment>
|
||||
<EuiSpacer />
|
||||
<EuiCallOut color="danger" size="s" title={expressionErrorMessage} />
|
||||
<EuiSpacer />
|
||||
</Fragment>
|
||||
) : null}
|
||||
<EuiTitle size="xs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
|
@ -408,6 +368,3 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent<
|
|||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export { EsQueryAlertTypeExpression as default };
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
import 'brace/theme/github';
|
||||
|
||||
import { EuiSpacer, EuiCallOut } from '@elastic/eui';
|
||||
import { RuleTypeParamsExpressionProps } from '../../../../../triggers_actions_ui/public';
|
||||
import { EsQueryAlertParams } from '../types';
|
||||
import { SearchSourceExpression } from './search_source_expression';
|
||||
import { EsQueryExpression } from './es_query_expression';
|
||||
import { isSearchSourceAlert } from '../util';
|
||||
|
||||
const expressionFieldsWithValidation = [
|
||||
'index',
|
||||
'size',
|
||||
'timeField',
|
||||
'threshold0',
|
||||
'threshold1',
|
||||
'timeWindowSize',
|
||||
'searchType',
|
||||
'esQuery',
|
||||
'searchConfiguration',
|
||||
];
|
||||
|
||||
export const EsQueryAlertTypeExpression: React.FunctionComponent<
|
||||
RuleTypeParamsExpressionProps<EsQueryAlertParams>
|
||||
> = (props) => {
|
||||
const { ruleParams, errors } = props;
|
||||
const isSearchSource = isSearchSourceAlert(ruleParams);
|
||||
|
||||
const hasExpressionErrors = !!Object.keys(errors).find((errorKey) => {
|
||||
return (
|
||||
expressionFieldsWithValidation.includes(errorKey) &&
|
||||
errors[errorKey].length >= 1 &&
|
||||
ruleParams[errorKey as keyof EsQueryAlertParams] !== undefined
|
||||
);
|
||||
});
|
||||
|
||||
const expressionErrorMessage = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.ui.alertParams.fixErrorInExpressionBelowValidationMessage',
|
||||
{
|
||||
defaultMessage: 'Expression contains errors.',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasExpressionErrors && (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
<EuiCallOut color="danger" size="s" title={expressionErrorMessage} />
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSearchSource ? (
|
||||
<SearchSourceExpression {...props} ruleParams={ruleParams} />
|
||||
) : (
|
||||
<EsQueryExpression {...props} ruleParams={ruleParams} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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 { EsQueryAlertTypeExpression } from './expression';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default EsQueryAlertTypeExpression;
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { injectI18n } from '@kbn/i18n-react';
|
||||
|
||||
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
|
||||
import {
|
||||
FilterItem,
|
||||
getDisplayValueFromFilter,
|
||||
} from '../../../../../../../src/plugins/data/public';
|
||||
import { Filter, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||
|
||||
const FilterItemComponent = injectI18n(FilterItem);
|
||||
|
||||
interface ReadOnlyFilterItemsProps {
|
||||
filters: Filter[];
|
||||
indexPatterns: IIndexPattern[];
|
||||
}
|
||||
|
||||
const noOp = () => {};
|
||||
|
||||
export const ReadOnlyFilterItems = ({ filters, indexPatterns }: ReadOnlyFilterItemsProps) => {
|
||||
const { uiSettings } = useKibana().services;
|
||||
|
||||
const filterList = filters.map((filter, index) => {
|
||||
const filterValue = getDisplayValueFromFilter(filter, indexPatterns);
|
||||
return (
|
||||
<EuiFlexItem grow={false} className="globalFilterBar__flexItem">
|
||||
<FilterItemComponent
|
||||
key={`${filter.meta.key}${filterValue}`}
|
||||
id={`${index}`}
|
||||
filter={filter}
|
||||
onUpdate={noOp}
|
||||
onRemove={noOp}
|
||||
indexPatterns={indexPatterns}
|
||||
uiSettings={uiSettings!}
|
||||
readonly
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
className="globalFilterBar"
|
||||
wrap={true}
|
||||
responsive={false}
|
||||
gutterSize="xs"
|
||||
alignItems="center"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{filterList}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
.searchSourceAlertFilters {
|
||||
.euiExpression__value {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
.dscExpressionParam.euiExpression {
|
||||
margin-left: 0;
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* 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 { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
|
||||
import React from 'react';
|
||||
import { dataPluginMock } from 'src/plugins/data/public/mocks';
|
||||
import { DataPublicPluginStart, ISearchStart } from 'src/plugins/data/public';
|
||||
import { EsQueryAlertParams, SearchType } from '../types';
|
||||
import { SearchSourceExpression } from './search_source_expression';
|
||||
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { EuiCallOut, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
|
||||
const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
search: ISearchStart & { searchSource: { create: jest.MockedFunction<any> } };
|
||||
};
|
||||
const chartsStartMock = chartPluginMock.createStartContract();
|
||||
|
||||
const defaultSearchSourceExpressionParams: EsQueryAlertParams<SearchType.searchSource> = {
|
||||
size: 100,
|
||||
thresholdComparator: '>',
|
||||
threshold: [0],
|
||||
timeWindowSize: 15,
|
||||
timeWindowUnit: 's',
|
||||
index: ['test-index'],
|
||||
timeField: '@timestamp',
|
||||
searchType: SearchType.searchSource,
|
||||
searchConfiguration: {},
|
||||
};
|
||||
|
||||
const searchSourceMock = {
|
||||
getField: (name: string) => {
|
||||
if (name === 'filter') {
|
||||
return [];
|
||||
}
|
||||
return '';
|
||||
},
|
||||
};
|
||||
|
||||
const setup = async (alertParams: EsQueryAlertParams<SearchType.searchSource>) => {
|
||||
const errors = {
|
||||
size: [],
|
||||
timeField: [],
|
||||
timeWindowSize: [],
|
||||
searchConfiguration: [],
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<SearchSourceExpression
|
||||
ruleInterval="1m"
|
||||
ruleThrottle="1m"
|
||||
alertNotifyWhen="onThrottleInterval"
|
||||
ruleParams={alertParams}
|
||||
setRuleParams={() => {}}
|
||||
setRuleProperty={() => {}}
|
||||
errors={errors}
|
||||
data={dataMock}
|
||||
defaultActionGroupId=""
|
||||
actionGroups={[]}
|
||||
charts={chartsStartMock}
|
||||
/>
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const rerender = async (wrapper: ReactWrapper) => {
|
||||
const update = async () =>
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
wrapper.update();
|
||||
});
|
||||
await update();
|
||||
};
|
||||
|
||||
describe('SearchSourceAlertTypeExpression', () => {
|
||||
test('should render loading prompt', async () => {
|
||||
dataMock.search.searchSource.create.mockImplementation(() =>
|
||||
Promise.resolve(() => searchSourceMock)
|
||||
);
|
||||
|
||||
const wrapper = await setup(defaultSearchSourceExpressionParams);
|
||||
|
||||
expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should render error prompt', async () => {
|
||||
dataMock.search.searchSource.create.mockImplementation(() =>
|
||||
Promise.reject(() => 'test error')
|
||||
);
|
||||
|
||||
const wrapper = await setup(defaultSearchSourceExpressionParams);
|
||||
await rerender(wrapper);
|
||||
|
||||
expect(wrapper.find(EuiCallOut).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should render SearchSourceAlertTypeExpression with expected components', async () => {
|
||||
dataMock.search.searchSource.create.mockImplementation(() =>
|
||||
Promise.resolve(() => searchSourceMock)
|
||||
);
|
||||
|
||||
const wrapper = await setup(defaultSearchSourceExpressionParams);
|
||||
await rerender(wrapper);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="sizeValueExpression"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy();
|
||||
expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* 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, { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import './search_source_expression.scss';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import {
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiExpression,
|
||||
EuiLoadingSpinner,
|
||||
EuiEmptyPrompt,
|
||||
EuiCallOut,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Filter, ISearchSource } from '../../../../../../../src/plugins/data/common';
|
||||
import { EsQueryAlertParams, SearchType } from '../types';
|
||||
import {
|
||||
ForLastExpression,
|
||||
RuleTypeParamsExpressionProps,
|
||||
ThresholdExpression,
|
||||
ValueExpression,
|
||||
} from '../../../../../triggers_actions_ui/public';
|
||||
import { DEFAULT_VALUES } from '../constants';
|
||||
import { ReadOnlyFilterItems } from './read_only_filter_items';
|
||||
|
||||
export const SearchSourceExpression = ({
|
||||
ruleParams,
|
||||
setRuleParams,
|
||||
setRuleProperty,
|
||||
data,
|
||||
errors,
|
||||
}: RuleTypeParamsExpressionProps<EsQueryAlertParams<SearchType.searchSource>>) => {
|
||||
const {
|
||||
searchConfiguration,
|
||||
thresholdComparator,
|
||||
threshold,
|
||||
timeWindowSize,
|
||||
timeWindowUnit,
|
||||
size,
|
||||
} = ruleParams;
|
||||
const [usedSearchSource, setUsedSearchSource] = useState<ISearchSource | undefined>();
|
||||
const [paramsError, setParamsError] = useState<Error | undefined>();
|
||||
|
||||
const [currentAlertParams, setCurrentAlertParams] = useState<
|
||||
EsQueryAlertParams<SearchType.searchSource>
|
||||
>({
|
||||
searchConfiguration,
|
||||
searchType: SearchType.searchSource,
|
||||
timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE,
|
||||
timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT,
|
||||
threshold: threshold ?? DEFAULT_VALUES.THRESHOLD,
|
||||
thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR,
|
||||
size: size ?? DEFAULT_VALUES.SIZE,
|
||||
});
|
||||
|
||||
const setParam = useCallback(
|
||||
(paramField: string, paramValue: unknown) => {
|
||||
setCurrentAlertParams((currentParams) => ({
|
||||
...currentParams,
|
||||
[paramField]: paramValue,
|
||||
}));
|
||||
setRuleParams(paramField, paramValue);
|
||||
},
|
||||
[setRuleParams]
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => setRuleProperty('params', currentAlertParams), []);
|
||||
|
||||
useEffect(() => {
|
||||
async function initSearchSource() {
|
||||
try {
|
||||
const loadedSearchSource = await data.search.searchSource.create(searchConfiguration);
|
||||
setUsedSearchSource(loadedSearchSource);
|
||||
} catch (error) {
|
||||
setParamsError(error);
|
||||
}
|
||||
}
|
||||
if (searchConfiguration) {
|
||||
initSearchSource();
|
||||
}
|
||||
}, [data.search.searchSource, searchConfiguration]);
|
||||
|
||||
if (paramsError) {
|
||||
return (
|
||||
<>
|
||||
<EuiCallOut color="danger" iconType="alert">
|
||||
<p>{paramsError.message}</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!usedSearchSource) {
|
||||
return <EuiEmptyPrompt title={<EuiLoadingSpinner size="xl" />} />;
|
||||
}
|
||||
|
||||
const dataView = usedSearchSource.getField('index')!;
|
||||
const query = usedSearchSource.getField('query')!;
|
||||
const filters = (usedSearchSource.getField('filter') as Filter[]).filter(
|
||||
({ meta }) => !meta.disabled
|
||||
);
|
||||
const dataViews = [dataView];
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiTitle size="xs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.searchThreshold.ui.conditionPrompt"
|
||||
defaultMessage="When the number of documents match"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.searchThreshold.ui.notEditable"
|
||||
defaultMessage="The data view, query, and filter are initialized in Discover and cannot be edited."
|
||||
/>
|
||||
}
|
||||
iconType="iInCircle"
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiExpression
|
||||
className="dscExpressionParam"
|
||||
description={'Data view'}
|
||||
value={dataView.title}
|
||||
display="columns"
|
||||
/>
|
||||
{query.query !== '' && (
|
||||
<EuiExpression
|
||||
className="dscExpressionParam"
|
||||
description={'Query'}
|
||||
value={query.query}
|
||||
display="columns"
|
||||
/>
|
||||
)}
|
||||
{filters.length > 0 && (
|
||||
<EuiExpression
|
||||
className="dscExpressionParam searchSourceAlertFilters"
|
||||
title={'sas'}
|
||||
description={'Filter'}
|
||||
value={<ReadOnlyFilterItems filters={filters} indexPatterns={dataViews} />}
|
||||
display="columns"
|
||||
/>
|
||||
)}
|
||||
|
||||
<EuiSpacer size="s" />
|
||||
<EuiTitle size="xs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.searchSource.ui.conditionPrompt"
|
||||
defaultMessage="When the number of matches"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<ThresholdExpression
|
||||
data-test-subj="thresholdExpression"
|
||||
thresholdComparator={thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR}
|
||||
threshold={threshold ?? DEFAULT_VALUES.THRESHOLD}
|
||||
errors={errors}
|
||||
display="fullWidth"
|
||||
popupPosition={'upLeft'}
|
||||
onChangeSelectedThreshold={(selectedThresholds) =>
|
||||
setParam('threshold', selectedThresholds)
|
||||
}
|
||||
onChangeSelectedThresholdComparator={(selectedThresholdComparator) =>
|
||||
setParam('thresholdComparator', selectedThresholdComparator)
|
||||
}
|
||||
/>
|
||||
<ForLastExpression
|
||||
data-test-subj="forLastExpression"
|
||||
popupPosition={'upLeft'}
|
||||
timeWindowSize={timeWindowSize}
|
||||
timeWindowUnit={timeWindowUnit}
|
||||
display="fullWidth"
|
||||
errors={errors}
|
||||
onChangeWindowSize={(selectedWindowSize: number | undefined) =>
|
||||
setParam('timeWindowSize', selectedWindowSize)
|
||||
}
|
||||
onChangeWindowUnit={(selectedWindowUnit: string) =>
|
||||
setParam('timeWindowUnit', selectedWindowUnit)
|
||||
}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiTitle size="xs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.stackAlerts.searchSource.ui.selectSizePrompt"
|
||||
defaultMessage="Select a size"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<ValueExpression
|
||||
description={i18n.translate('xpack.stackAlerts.searchSource.ui.sizeExpression', {
|
||||
defaultMessage: 'Size',
|
||||
})}
|
||||
data-test-subj="sizeValueExpression"
|
||||
value={size}
|
||||
errors={errors.size}
|
||||
display="fullWidth"
|
||||
popupPosition={'upLeft'}
|
||||
onChangeSelectedValue={(updatedValue) => {
|
||||
setParam('size', updatedValue);
|
||||
}}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
|
@ -8,12 +8,19 @@
|
|||
import { lazy } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { validateExpression } from './validation';
|
||||
import { EsQueryAlertParams } from './types';
|
||||
import { EsQueryAlertParams, SearchType } from './types';
|
||||
import { RuleTypeModel } from '../../../../triggers_actions_ui/public';
|
||||
import { PluginSetupContract as AlertingSetup } from '../../../../alerting/public';
|
||||
import { SanitizedAlert } from '../../../../alerting/common';
|
||||
|
||||
const PLUGIN_ID = 'discover';
|
||||
const ES_QUERY_ALERT_TYPE = '.es-query';
|
||||
|
||||
export function getAlertType(alerting: AlertingSetup): RuleTypeModel<EsQueryAlertParams> {
|
||||
registerNavigation(alerting);
|
||||
|
||||
export function getAlertType(): RuleTypeModel<EsQueryAlertParams> {
|
||||
return {
|
||||
id: '.es-query',
|
||||
id: ES_QUERY_ALERT_TYPE,
|
||||
description: i18n.translate('xpack.stackAlerts.esQuery.ui.alertType.descriptionText', {
|
||||
defaultMessage: 'Alert when matches are found during the latest query run.',
|
||||
}),
|
||||
|
@ -28,9 +35,20 @@ export function getAlertType(): RuleTypeModel<EsQueryAlertParams> {
|
|||
|
||||
- Value: \\{\\{context.value\\}\\}
|
||||
- Conditions Met: \\{\\{context.conditions\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\}
|
||||
- Timestamp: \\{\\{context.date\\}\\}`,
|
||||
- Timestamp: \\{\\{context.date\\}\\}
|
||||
- Link: \\{\\{context.link\\}\\}`,
|
||||
}
|
||||
),
|
||||
requiresAppContext: false,
|
||||
};
|
||||
}
|
||||
|
||||
function registerNavigation(alerting: AlertingSetup) {
|
||||
alerting.registerNavigation(
|
||||
PLUGIN_ID,
|
||||
ES_QUERY_ALERT_TYPE,
|
||||
(alert: SanitizedAlert<EsQueryAlertParams<SearchType.searchSource>>) => {
|
||||
return `#/viewAlert/${alert.id}`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { AlertTypeParams } from '../../../../alerting/common';
|
||||
import { SerializedSearchSourceFields } from '../../../../../../src/plugins/data/common';
|
||||
|
||||
export interface Comparator {
|
||||
text: string;
|
||||
|
@ -13,13 +14,29 @@ export interface Comparator {
|
|||
requiredValues: number;
|
||||
}
|
||||
|
||||
export interface EsQueryAlertParams extends AlertTypeParams {
|
||||
index: string[];
|
||||
timeField?: string;
|
||||
esQuery: string;
|
||||
export enum SearchType {
|
||||
esQuery = 'esQuery',
|
||||
searchSource = 'searchSource',
|
||||
}
|
||||
|
||||
export interface CommonAlertParams<T extends SearchType> extends AlertTypeParams {
|
||||
size: number;
|
||||
thresholdComparator?: string;
|
||||
threshold: number[];
|
||||
timeWindowSize: number;
|
||||
timeWindowUnit: string;
|
||||
}
|
||||
|
||||
export type EsQueryAlertParams<T = SearchType> = T extends SearchType.searchSource
|
||||
? CommonAlertParams<SearchType.searchSource> & OnlySearchSourceAlertParams
|
||||
: CommonAlertParams<SearchType.esQuery> & OnlyEsQueryAlertParams;
|
||||
|
||||
export interface OnlyEsQueryAlertParams {
|
||||
esQuery: string;
|
||||
index: string[];
|
||||
timeField: string;
|
||||
}
|
||||
export interface OnlySearchSourceAlertParams {
|
||||
searchType: 'searchSource';
|
||||
searchConfiguration: SerializedSearchSourceFields;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 { EsQueryAlertParams, SearchType } from './types';
|
||||
|
||||
export const isSearchSourceAlert = (
|
||||
ruleParams: EsQueryAlertParams
|
||||
): ruleParams is EsQueryAlertParams<SearchType.searchSource> => {
|
||||
return ruleParams.searchType === 'searchSource';
|
||||
};
|
|
@ -5,64 +5,82 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EsQueryAlertParams } from './types';
|
||||
import { EsQueryAlertParams, SearchType } from './types';
|
||||
import { validateExpression } from './validation';
|
||||
|
||||
describe('expression params validation', () => {
|
||||
test('if index property is invalid should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
|
||||
index: [],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
timeField: '',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.index[0]).toBe('Index is required.');
|
||||
});
|
||||
|
||||
test('if timeField property is not defined should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
timeField: '',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.timeField.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.timeField[0]).toBe('Time field is required.');
|
||||
});
|
||||
|
||||
test('if esQuery property is invalid JSON should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n`,
|
||||
size: 100,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
timeField: '',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.esQuery[0]).toBe('Query must be valid JSON.');
|
||||
});
|
||||
|
||||
test('if esQuery property is invalid should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"aggs\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
timeField: '',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.esQuery[0]).toBe(`Query field is required.`);
|
||||
});
|
||||
|
||||
test('if searchConfiguration property is not set should return proper error message', () => {
|
||||
const initialParams = {
|
||||
size: 100,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
searchType: SearchType.searchSource,
|
||||
} as EsQueryAlertParams<SearchType.searchSource>;
|
||||
expect(validateExpression(initialParams).errors.searchConfiguration.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.searchConfiguration[0]).toBe(
|
||||
`Search source configuration is required.`
|
||||
);
|
||||
});
|
||||
|
||||
test('if threshold0 property is not set should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
|
@ -70,13 +88,14 @@ describe('expression params validation', () => {
|
|||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
thresholdComparator: '<',
|
||||
timeField: '',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.threshold0.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.threshold0[0]).toBe('Threshold 0 is required.');
|
||||
});
|
||||
|
||||
test('if threshold1 property is needed by thresholdComparator but not set should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
|
@ -84,13 +103,14 @@ describe('expression params validation', () => {
|
|||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
thresholdComparator: 'between',
|
||||
timeField: '',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.threshold1[0]).toBe('Threshold 1 is required.');
|
||||
});
|
||||
|
||||
test('if threshold0 property greater than threshold1 property should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
|
@ -98,6 +118,7 @@ describe('expression params validation', () => {
|
|||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
thresholdComparator: 'between',
|
||||
timeField: '',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.threshold1[0]).toBe(
|
||||
|
@ -106,13 +127,14 @@ describe('expression params validation', () => {
|
|||
});
|
||||
|
||||
test('if size property is < 0 should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n`,
|
||||
size: -1,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
timeField: '',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.size.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.size[0]).toBe(
|
||||
|
@ -121,13 +143,14 @@ describe('expression params validation', () => {
|
|||
});
|
||||
|
||||
test('if size property is > 10000 should return proper error message', () => {
|
||||
const initialParams: EsQueryAlertParams = {
|
||||
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
|
||||
index: ['test'],
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n`,
|
||||
size: 25000,
|
||||
timeWindowSize: 1,
|
||||
timeWindowUnit: 's',
|
||||
threshold: [0],
|
||||
timeField: '',
|
||||
};
|
||||
expect(validateExpression(initialParams).errors.size.length).toBeGreaterThan(0);
|
||||
expect(validateExpression(initialParams).errors.size[0]).toBe(
|
||||
|
|
|
@ -8,10 +8,10 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { EsQueryAlertParams } from './types';
|
||||
import { ValidationResult, builtInComparators } from '../../../../triggers_actions_ui/public';
|
||||
import { isSearchSourceAlert } from './util';
|
||||
|
||||
export const validateExpression = (alertParams: EsQueryAlertParams): ValidationResult => {
|
||||
const { index, timeField, esQuery, size, threshold, timeWindowSize, thresholdComparator } =
|
||||
alertParams;
|
||||
const { size, threshold, timeWindowSize, thresholdComparator } = alertParams;
|
||||
const validationResult = { errors: {} };
|
||||
const errors = {
|
||||
index: new Array<string>(),
|
||||
|
@ -22,46 +22,9 @@ export const validateExpression = (alertParams: EsQueryAlertParams): ValidationR
|
|||
threshold1: new Array<string>(),
|
||||
thresholdComparator: new Array<string>(),
|
||||
timeWindowSize: new Array<string>(),
|
||||
searchConfiguration: new Array<string>(),
|
||||
};
|
||||
validationResult.errors = errors;
|
||||
if (!index || index.length === 0) {
|
||||
errors.index.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredIndexText', {
|
||||
defaultMessage: 'Index is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!timeField) {
|
||||
errors.timeField.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeFieldText', {
|
||||
defaultMessage: 'Time field is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!esQuery) {
|
||||
errors.esQuery.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredQueryText', {
|
||||
defaultMessage: 'Elasticsearch query is required.',
|
||||
})
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const parsedQuery = JSON.parse(esQuery);
|
||||
if (!parsedQuery.query) {
|
||||
errors.esQuery.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredEsQueryText', {
|
||||
defaultMessage: `Query field is required.`,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.esQuery.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.jsonQueryText', {
|
||||
defaultMessage: 'Query must be valid JSON.',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!threshold || threshold.length === 0 || threshold[0] === undefined) {
|
||||
errors.threshold0.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold0Text', {
|
||||
|
@ -96,6 +59,7 @@ export const validateExpression = (alertParams: EsQueryAlertParams): ValidationR
|
|||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!size) {
|
||||
errors.size.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredSizeText', {
|
||||
|
@ -111,5 +75,66 @@ export const validateExpression = (alertParams: EsQueryAlertParams): ValidationR
|
|||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip esQuery and index params check if it is search source alert,
|
||||
* since it should contain searchConfiguration instead of esQuery and index.
|
||||
*/
|
||||
const isSearchSource = isSearchSourceAlert(alertParams);
|
||||
if (isSearchSource) {
|
||||
if (!alertParams.searchConfiguration) {
|
||||
errors.searchConfiguration.push(
|
||||
i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.ui.validation.error.requiredSearchConfiguration',
|
||||
{
|
||||
defaultMessage: 'Search source configuration is required.',
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
if (!alertParams.index || alertParams.index.length === 0) {
|
||||
errors.index.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredIndexText', {
|
||||
defaultMessage: 'Index is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!alertParams.timeField) {
|
||||
errors.timeField.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeFieldText', {
|
||||
defaultMessage: 'Time field is required.',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!alertParams.esQuery) {
|
||||
errors.esQuery.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredQueryText', {
|
||||
defaultMessage: 'Elasticsearch query is required.',
|
||||
})
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const parsedQuery = JSON.parse(alertParams.esQuery);
|
||||
if (!parsedQuery.query) {
|
||||
errors.esQuery.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredEsQueryText', {
|
||||
defaultMessage: `Query field is required.`,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.esQuery.push(
|
||||
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.jsonQueryText', {
|
||||
defaultMessage: 'Query must be valid JSON.',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
};
|
||||
|
|
|
@ -10,15 +10,18 @@ import { getAlertType as getThresholdAlertType } from './threshold';
|
|||
import { getAlertType as getEsQueryAlertType } from './es_query';
|
||||
import { Config } from '../../common';
|
||||
import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public';
|
||||
import { PluginSetupContract as AlertingSetup } from '../../../alerting/public';
|
||||
|
||||
export function registerAlertTypes({
|
||||
ruleTypeRegistry,
|
||||
config,
|
||||
alerting,
|
||||
}: {
|
||||
ruleTypeRegistry: TriggersAndActionsUIPublicPluginSetup['ruleTypeRegistry'];
|
||||
config: Config;
|
||||
alerting: AlertingSetup;
|
||||
}) {
|
||||
ruleTypeRegistry.register(getGeoContainmentAlertType());
|
||||
ruleTypeRegistry.register(getThresholdAlertType());
|
||||
ruleTypeRegistry.register(getEsQueryAlertType());
|
||||
ruleTypeRegistry.register(getEsQueryAlertType(alerting));
|
||||
}
|
||||
|
|
|
@ -9,12 +9,14 @@ import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public';
|
|||
import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public';
|
||||
import { registerAlertTypes } from './alert_types';
|
||||
import { Config } from '../common';
|
||||
import { PluginSetupContract as AlertingSetup } from '../../alerting/public';
|
||||
|
||||
export type Setup = void;
|
||||
export type Start = void;
|
||||
|
||||
export interface StackAlertsPublicSetupDeps {
|
||||
triggersActionsUi: TriggersAndActionsUIPublicPluginSetup;
|
||||
alerting: AlertingSetup;
|
||||
}
|
||||
|
||||
export class StackAlertsPublicPlugin implements Plugin<Setup, Start, StackAlertsPublicSetupDeps> {
|
||||
|
@ -24,10 +26,11 @@ export class StackAlertsPublicPlugin implements Plugin<Setup, Start, StackAlerts
|
|||
this.initializerContext = initializerContext;
|
||||
}
|
||||
|
||||
public setup(core: CoreSetup, { triggersActionsUi }: StackAlertsPublicSetupDeps) {
|
||||
public setup(core: CoreSetup, { triggersActionsUi, alerting }: StackAlertsPublicSetupDeps) {
|
||||
registerAlertTypes({
|
||||
ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry,
|
||||
config: this.initializerContext.config.get<Config>(),
|
||||
alerting,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { EsQueryAlertActionContext, addMessages } from './action_context';
|
||||
import { EsQueryAlertParamsSchema } from './alert_type_params';
|
||||
import { OnlyEsQueryAlertParams } from './types';
|
||||
|
||||
describe('ActionContext', () => {
|
||||
it('generates expected properties', async () => {
|
||||
|
@ -19,12 +20,13 @@ describe('ActionContext', () => {
|
|||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '>',
|
||||
threshold: [4],
|
||||
});
|
||||
}) as OnlyEsQueryAlertParams;
|
||||
const base: EsQueryAlertActionContext = {
|
||||
date: '2020-01-01T00:00:00.000Z',
|
||||
value: 42,
|
||||
conditions: 'count greater than 4',
|
||||
hits: [],
|
||||
link: 'link-mock',
|
||||
};
|
||||
const context = addMessages({ name: '[alert-name]' }, base, params);
|
||||
expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`);
|
||||
|
@ -33,7 +35,8 @@ describe('ActionContext', () => {
|
|||
|
||||
- Value: 42
|
||||
- Conditions Met: count greater than 4 over 5m
|
||||
- Timestamp: 2020-01-01T00:00:00.000Z`
|
||||
- Timestamp: 2020-01-01T00:00:00.000Z
|
||||
- Link: link-mock`
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -47,12 +50,13 @@ describe('ActionContext', () => {
|
|||
timeWindowUnit: 'm',
|
||||
thresholdComparator: 'between',
|
||||
threshold: [4, 5],
|
||||
});
|
||||
}) as OnlyEsQueryAlertParams;
|
||||
const base: EsQueryAlertActionContext = {
|
||||
date: '2020-01-01T00:00:00.000Z',
|
||||
value: 4,
|
||||
conditions: 'count between 4 and 5',
|
||||
hits: [],
|
||||
link: 'link-mock',
|
||||
};
|
||||
const context = addMessages({ name: '[alert-name]' }, base, params);
|
||||
expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`);
|
||||
|
@ -61,7 +65,8 @@ describe('ActionContext', () => {
|
|||
|
||||
- Value: 4
|
||||
- Conditions Met: count between 4 and 5 over 5m
|
||||
- Timestamp: 2020-01-01T00:00:00.000Z`
|
||||
- Timestamp: 2020-01-01T00:00:00.000Z
|
||||
- Link: link-mock`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { AlertExecutorOptions, AlertInstanceContext } from '../../../../alerting/server';
|
||||
import { EsQueryAlertParams } from './alert_type_params';
|
||||
import { OnlyEsQueryAlertParams, OnlySearchSourceAlertParams } from './types';
|
||||
|
||||
// alert type context provided to actions
|
||||
|
||||
|
@ -30,12 +30,15 @@ export interface EsQueryAlertActionContext extends AlertInstanceContext {
|
|||
conditions: string;
|
||||
// query matches
|
||||
hits: estypes.SearchHit[];
|
||||
// a link to see records that triggered the alert for Discover alert
|
||||
// a link which navigates to stack management in case of Elastic query alert
|
||||
link: string;
|
||||
}
|
||||
|
||||
export function addMessages(
|
||||
alertInfo: AlertInfo,
|
||||
baseContext: EsQueryAlertActionContext,
|
||||
params: EsQueryAlertParams
|
||||
params: OnlyEsQueryAlertParams | OnlySearchSourceAlertParams
|
||||
): ActionContext {
|
||||
const title = i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle', {
|
||||
defaultMessage: `alert '{name}' matched query`,
|
||||
|
@ -50,13 +53,15 @@ export function addMessages(
|
|||
|
||||
- Value: {value}
|
||||
- Conditions Met: {conditions} over {window}
|
||||
- Timestamp: {date}`,
|
||||
- Timestamp: {date}
|
||||
- Link: {link}`,
|
||||
values: {
|
||||
name: alertInfo.name,
|
||||
value: baseContext.value,
|
||||
conditions: baseContext.conditions,
|
||||
window,
|
||||
date: baseContext.date,
|
||||
link: baseContext.link,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -6,26 +6,23 @@
|
|||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { Logger } from 'src/core/server';
|
||||
import { RuleType, AlertExecutorOptions } from '../../types';
|
||||
import { ActionContext, EsQueryAlertActionContext, addMessages } from './action_context';
|
||||
import { CoreSetup, Logger } from 'kibana/server';
|
||||
import { RuleType } from '../../types';
|
||||
import { ActionContext } from './action_context';
|
||||
import {
|
||||
EsQueryAlertParams,
|
||||
EsQueryAlertParamsSchema,
|
||||
EsQueryAlertState,
|
||||
} from './alert_type_params';
|
||||
import { STACK_ALERTS_FEATURE_ID } from '../../../common';
|
||||
import { ComparatorFns, getHumanReadableComparator } from '../lib';
|
||||
import { parseDuration } from '../../../../alerting/server';
|
||||
import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query';
|
||||
import { ExecutorOptions } from './types';
|
||||
import { ActionGroupId, ES_QUERY_ID } from './constants';
|
||||
import { executor } from './executor';
|
||||
|
||||
export const ES_QUERY_ID = '.es-query';
|
||||
|
||||
export const ActionGroupId = 'query matched';
|
||||
export const ConditionMetAlertInstanceId = 'query matched';
|
||||
|
||||
export function getAlertType(logger: Logger): RuleType<
|
||||
export function getAlertType(
|
||||
logger: Logger,
|
||||
core: CoreSetup
|
||||
): RuleType<
|
||||
EsQueryAlertParams,
|
||||
never, // Only use if defining useSavedObjectReferences hook
|
||||
EsQueryAlertState,
|
||||
|
@ -101,14 +98,14 @@ export function getAlertType(logger: Logger): RuleType<
|
|||
'xpack.stackAlerts.esQuery.actionVariableContextThresholdLabel',
|
||||
{
|
||||
defaultMessage:
|
||||
"An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.",
|
||||
"An array of values to use as the threshold. 'between' and 'notBetween' require two values.",
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextThresholdComparatorLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextThresholdComparatorLabel',
|
||||
{
|
||||
defaultMessage: 'A function to determine if the threshold has been met.',
|
||||
defaultMessage: 'A function to determine if the threshold was met.',
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -119,6 +116,22 @@ export function getAlertType(logger: Logger): RuleType<
|
|||
}
|
||||
);
|
||||
|
||||
const actionVariableSearchConfigurationLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextSearchConfigurationLabel',
|
||||
{
|
||||
defaultMessage:
|
||||
'Serialized search source fields used to fetch the documents from Elasticsearch.',
|
||||
}
|
||||
);
|
||||
|
||||
const actionVariableContextLinkLabel = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.actionVariableContextLinkLabel',
|
||||
{
|
||||
defaultMessage: `Navigate to Discover and show the records that triggered
|
||||
the alert when the rule is created in Discover. Otherwise, navigate to the status page for the rule.`,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: ES_QUERY_ID,
|
||||
name: alertTypeName,
|
||||
|
@ -135,214 +148,22 @@ export function getAlertType(logger: Logger): RuleType<
|
|||
{ name: 'value', description: actionVariableContextValueLabel },
|
||||
{ name: 'hits', description: actionVariableContextHitsLabel },
|
||||
{ name: 'conditions', description: actionVariableContextConditionsLabel },
|
||||
{ name: 'link', description: actionVariableContextLinkLabel },
|
||||
],
|
||||
params: [
|
||||
{ name: 'index', description: actionVariableContextIndexLabel },
|
||||
{ name: 'esQuery', description: actionVariableContextQueryLabel },
|
||||
{ name: 'size', description: actionVariableContextSizeLabel },
|
||||
{ name: 'threshold', description: actionVariableContextThresholdLabel },
|
||||
{ name: 'thresholdComparator', description: actionVariableContextThresholdComparatorLabel },
|
||||
{ name: 'searchConfiguration', description: actionVariableSearchConfigurationLabel },
|
||||
{ name: 'esQuery', description: actionVariableContextQueryLabel },
|
||||
{ name: 'index', description: actionVariableContextIndexLabel },
|
||||
],
|
||||
},
|
||||
minimumLicenseRequired: 'basic',
|
||||
isExportable: true,
|
||||
executor,
|
||||
executor: async (options: ExecutorOptions<EsQueryAlertParams>) => {
|
||||
return await executor(logger, core, options);
|
||||
},
|
||||
producer: STACK_ALERTS_FEATURE_ID,
|
||||
};
|
||||
|
||||
async function executor(
|
||||
options: AlertExecutorOptions<
|
||||
EsQueryAlertParams,
|
||||
EsQueryAlertState,
|
||||
{},
|
||||
ActionContext,
|
||||
typeof ActionGroupId
|
||||
>
|
||||
) {
|
||||
const { alertId, name, services, params, state } = options;
|
||||
const { alertFactory, scopedClusterClient } = services;
|
||||
const previousTimestamp = state.latestTimestamp;
|
||||
|
||||
const esClient = scopedClusterClient.asCurrentUser;
|
||||
const { parsedQuery, dateStart, dateEnd } = getSearchParams(params);
|
||||
|
||||
const compareFn = ComparatorFns.get(params.thresholdComparator);
|
||||
if (compareFn == null) {
|
||||
throw new Error(getInvalidComparatorError(params.thresholdComparator));
|
||||
}
|
||||
|
||||
// During each alert execution, we run the configured query, get a hit count
|
||||
// (hits.total) and retrieve up to params.size hits. We
|
||||
// evaluate the threshold condition using the value of hits.total. If the threshold
|
||||
// condition is met, the hits are counted toward the query match and we update
|
||||
// the alert state with the timestamp of the latest hit. In the next execution
|
||||
// of the alert, the latestTimestamp will be used to gate the query in order to
|
||||
// avoid counting a document multiple times.
|
||||
|
||||
let timestamp: string | undefined = tryToParseAsDate(previousTimestamp);
|
||||
const filter = timestamp
|
||||
? {
|
||||
bool: {
|
||||
filter: [
|
||||
parsedQuery.query,
|
||||
{
|
||||
bool: {
|
||||
must_not: [
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
[params.timeField]: {
|
||||
lte: timestamp,
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: parsedQuery.query;
|
||||
|
||||
const query = buildSortedEventsQuery({
|
||||
index: params.index,
|
||||
from: dateStart,
|
||||
to: dateEnd,
|
||||
filter,
|
||||
size: params.size,
|
||||
sortOrder: 'desc',
|
||||
searchAfterSortId: undefined,
|
||||
timeField: params.timeField,
|
||||
track_total_hits: true,
|
||||
});
|
||||
|
||||
logger.debug(`alert ${ES_QUERY_ID}:${alertId} "${name}" query - ${JSON.stringify(query)}`);
|
||||
|
||||
const { body: searchResult } = await esClient.search(query, { meta: true });
|
||||
|
||||
logger.debug(
|
||||
`alert ${ES_QUERY_ID}:${alertId} "${name}" result - ${JSON.stringify(searchResult)}`
|
||||
);
|
||||
|
||||
const numMatches = (searchResult.hits.total as estypes.SearchTotalHits).value;
|
||||
|
||||
// apply the alert condition
|
||||
const conditionMet = compareFn(numMatches, params.threshold);
|
||||
|
||||
if (conditionMet) {
|
||||
const humanFn = i18n.translate(
|
||||
'xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription',
|
||||
{
|
||||
defaultMessage: `Number of matching documents is {thresholdComparator} {threshold}`,
|
||||
values: {
|
||||
thresholdComparator: getHumanReadableComparator(params.thresholdComparator),
|
||||
threshold: params.threshold.join(' and '),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const baseContext: EsQueryAlertActionContext = {
|
||||
date: new Date().toISOString(),
|
||||
value: numMatches,
|
||||
conditions: humanFn,
|
||||
hits: searchResult.hits.hits,
|
||||
};
|
||||
|
||||
const actionContext = addMessages(options, baseContext, params);
|
||||
const alertInstance = alertFactory.create(ConditionMetAlertInstanceId);
|
||||
alertInstance
|
||||
// store the params we would need to recreate the query that led to this alert instance
|
||||
.replaceState({ latestTimestamp: timestamp, dateStart, dateEnd })
|
||||
.scheduleActions(ActionGroupId, actionContext);
|
||||
|
||||
// update the timestamp based on the current search results
|
||||
const firstValidTimefieldSort = getValidTimefieldSort(
|
||||
searchResult.hits.hits.find((hit) => getValidTimefieldSort(hit.sort))?.sort
|
||||
);
|
||||
if (firstValidTimefieldSort) {
|
||||
timestamp = firstValidTimefieldSort;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
latestTimestamp: timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getValidTimefieldSort(sortValues: Array<string | number | null> = []): undefined | string {
|
||||
for (const sortValue of sortValues) {
|
||||
const sortDate = tryToParseAsDate(sortValue);
|
||||
if (sortDate) {
|
||||
return sortDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
function tryToParseAsDate(sortValue?: string | number | null): undefined | string {
|
||||
const sortDate = typeof sortValue === 'string' ? Date.parse(sortValue) : sortValue;
|
||||
if (sortDate && !isNaN(sortDate)) {
|
||||
return new Date(sortDate).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
function getInvalidComparatorError(comparator: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', {
|
||||
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
|
||||
values: {
|
||||
comparator,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getInvalidWindowSizeError(windowValue: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', {
|
||||
defaultMessage: 'invalid format for windowSize: "{windowValue}"',
|
||||
values: {
|
||||
windowValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getInvalidQueryError(query: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
|
||||
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
|
||||
values: {
|
||||
query,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchParams(queryParams: EsQueryAlertParams) {
|
||||
const date = Date.now();
|
||||
const { esQuery, timeWindowSize, timeWindowUnit } = queryParams;
|
||||
|
||||
let parsedQuery;
|
||||
try {
|
||||
parsedQuery = JSON.parse(esQuery);
|
||||
} catch (err) {
|
||||
throw new Error(getInvalidQueryError(esQuery));
|
||||
}
|
||||
|
||||
if (parsedQuery && !parsedQuery.query) {
|
||||
throw new Error(getInvalidQueryError(esQuery));
|
||||
}
|
||||
|
||||
const window = `${timeWindowSize}${timeWindowUnit}`;
|
||||
let timeWindow: number;
|
||||
try {
|
||||
timeWindow = parseDuration(window);
|
||||
} catch (err) {
|
||||
throw new Error(getInvalidWindowSizeError(window));
|
||||
}
|
||||
|
||||
const dateStart = new Date(date - timeWindow).toISOString();
|
||||
const dateEnd = new Date(date).toISOString();
|
||||
|
||||
return { parsedQuery, dateStart, dateEnd };
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { TypeOf } from '@kbn/config-schema';
|
||||
import type { Writable } from '@kbn/utility-types';
|
||||
import { Comparator } from '../../../common/comparator_types';
|
||||
import {
|
||||
EsQueryAlertParamsSchema,
|
||||
EsQueryAlertParams,
|
||||
|
@ -20,7 +21,7 @@ const DefaultParams: Writable<Partial<EsQueryAlertParams>> = {
|
|||
size: 100,
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '>',
|
||||
thresholdComparator: Comparator.GT,
|
||||
threshold: [0],
|
||||
};
|
||||
|
||||
|
|
|
@ -6,10 +6,11 @@
|
|||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { ComparatorFnNames } from '../lib';
|
||||
import { schema, Type, TypeOf } from '@kbn/config-schema';
|
||||
import { validateTimeWindowUnits } from '../../../../triggers_actions_ui/server';
|
||||
import { AlertTypeState } from '../../../../alerting/server';
|
||||
import { Comparator } from '../../../common/comparator_types';
|
||||
import { ComparatorFnNames } from '../lib';
|
||||
|
||||
export const ES_QUERY_MAX_HITS_PER_EXECUTION = 10000;
|
||||
|
||||
|
@ -19,15 +20,39 @@ export interface EsQueryAlertState extends AlertTypeState {
|
|||
latestTimestamp: string | undefined;
|
||||
}
|
||||
|
||||
export const EsQueryAlertParamsSchemaProperties = {
|
||||
index: schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
|
||||
timeField: schema.string({ minLength: 1 }),
|
||||
esQuery: schema.string({ minLength: 1 }),
|
||||
const EsQueryAlertParamsSchemaProperties = {
|
||||
size: schema.number({ min: 0, max: ES_QUERY_MAX_HITS_PER_EXECUTION }),
|
||||
timeWindowSize: schema.number({ min: 1 }),
|
||||
timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }),
|
||||
threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }),
|
||||
thresholdComparator: schema.string({ validate: validateComparator }),
|
||||
thresholdComparator: schema.string({ validate: validateComparator }) as Type<Comparator>,
|
||||
searchType: schema.nullable(schema.literal('searchSource')),
|
||||
// searchSource alert param only
|
||||
searchConfiguration: schema.conditional(
|
||||
schema.siblingRef('searchType'),
|
||||
schema.literal('searchSource'),
|
||||
schema.object({}, { unknowns: 'allow' }),
|
||||
schema.never()
|
||||
),
|
||||
// esQuery alert params only
|
||||
esQuery: schema.conditional(
|
||||
schema.siblingRef('searchType'),
|
||||
schema.literal('searchSource'),
|
||||
schema.never(),
|
||||
schema.string({ minLength: 1 })
|
||||
),
|
||||
index: schema.conditional(
|
||||
schema.siblingRef('searchType'),
|
||||
schema.literal('searchSource'),
|
||||
schema.never(),
|
||||
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 })
|
||||
),
|
||||
timeField: schema.conditional(
|
||||
schema.siblingRef('searchType'),
|
||||
schema.literal('searchSource'),
|
||||
schema.never(),
|
||||
schema.string({ minLength: 1 })
|
||||
),
|
||||
};
|
||||
|
||||
export const EsQueryAlertParamsSchema = schema.object(EsQueryAlertParamsSchemaProperties, {
|
||||
|
@ -38,8 +63,7 @@ const betweenComparators = new Set(['between', 'notBetween']);
|
|||
|
||||
// using direct type not allowed, circular reference, so body is typed to any
|
||||
function validateParams(anyParams: unknown): string | undefined {
|
||||
const { esQuery, thresholdComparator, threshold }: EsQueryAlertParams =
|
||||
anyParams as EsQueryAlertParams;
|
||||
const { esQuery, thresholdComparator, threshold, searchType } = anyParams as EsQueryAlertParams;
|
||||
|
||||
if (betweenComparators.has(thresholdComparator) && threshold.length === 1) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidThreshold2ErrorMessage', {
|
||||
|
@ -51,6 +75,10 @@ function validateParams(anyParams: unknown): string | undefined {
|
|||
});
|
||||
}
|
||||
|
||||
if (searchType === 'searchSource') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedQuery = JSON.parse(esQuery);
|
||||
|
||||
|
@ -66,8 +94,8 @@ function validateParams(anyParams: unknown): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
export function validateComparator(comparator: string): string | undefined {
|
||||
if (ComparatorFnNames.has(comparator)) return;
|
||||
function validateComparator(comparator: string): string | undefined {
|
||||
if (ComparatorFnNames.has(comparator as Comparator)) return;
|
||||
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', {
|
||||
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const ES_QUERY_ID = '.es-query';
|
||||
export const ActionGroupId = 'query matched';
|
||||
export const ConditionMetAlertInstanceId = 'query matched';
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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 { getSearchParams, getValidTimefieldSort, tryToParseAsDate } from './executor';
|
||||
import { OnlyEsQueryAlertParams } from './types';
|
||||
|
||||
describe('es_query executor', () => {
|
||||
const defaultProps = {
|
||||
size: 3,
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
threshold: [],
|
||||
thresholdComparator: '>=',
|
||||
esQuery: '{ "query": "test-query" }',
|
||||
index: ['test-index'],
|
||||
timeField: '',
|
||||
};
|
||||
describe('tryToParseAsDate', () => {
|
||||
it.each<[string | number]>([['2019-01-01T00:00:00.000Z'], [1546300800000]])(
|
||||
'should parse as date correctly',
|
||||
(value) => {
|
||||
expect(tryToParseAsDate(value)).toBe('2019-01-01T00:00:00.000Z');
|
||||
}
|
||||
);
|
||||
it.each<[string | null | undefined]>([[null], ['invalid date'], [undefined]])(
|
||||
'should not parse as date',
|
||||
(value) => {
|
||||
expect(tryToParseAsDate(value)).toBe(undefined);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('getValidTimefieldSort', () => {
|
||||
it('should return valid time field', () => {
|
||||
const result = getValidTimefieldSort([
|
||||
null,
|
||||
'invalid date',
|
||||
'2018-12-31T19:00:00.000Z',
|
||||
1546282800000,
|
||||
]);
|
||||
expect(result).toEqual('2018-12-31T19:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSearchParams', () => {
|
||||
it('should return search params correctly', () => {
|
||||
const result = getSearchParams(defaultProps as OnlyEsQueryAlertParams);
|
||||
expect(result.parsedQuery.query).toBe('test-query');
|
||||
});
|
||||
|
||||
it('should throw invalid query error', () => {
|
||||
expect(() =>
|
||||
getSearchParams({ ...defaultProps, esQuery: '' } as OnlyEsQueryAlertParams)
|
||||
).toThrow('invalid query specified: "" - query must be JSON');
|
||||
});
|
||||
|
||||
it('should throw invalid query error due to missing query property', () => {
|
||||
expect(() =>
|
||||
getSearchParams({
|
||||
...defaultProps,
|
||||
esQuery: '{ "someProperty": "test-query" }',
|
||||
} as OnlyEsQueryAlertParams)
|
||||
).toThrow('invalid query specified: "{ "someProperty": "test-query" }" - query must be JSON');
|
||||
});
|
||||
|
||||
it('should throw invalid window size error', () => {
|
||||
expect(() =>
|
||||
getSearchParams({
|
||||
...defaultProps,
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'r',
|
||||
} as OnlyEsQueryAlertParams)
|
||||
).toThrow('invalid format for windowSize: "5r"');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,193 @@
|
|||
/*
|
||||
* 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 { sha256 } from 'js-sha256';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { CoreSetup, Logger } from 'kibana/server';
|
||||
import { addMessages, EsQueryAlertActionContext } from './action_context';
|
||||
import { ComparatorFns, getHumanReadableComparator } from '../lib';
|
||||
import { parseDuration } from '../../../../alerting/server';
|
||||
import { ExecutorOptions, OnlyEsQueryAlertParams, OnlySearchSourceAlertParams } from './types';
|
||||
import { ActionGroupId, ConditionMetAlertInstanceId } from './constants';
|
||||
import { fetchEsQuery } from './lib/fetch_es_query';
|
||||
import { EsQueryAlertParams } from './alert_type_params';
|
||||
import { fetchSearchSourceQuery } from './lib/fetch_search_source_query';
|
||||
import { Comparator } from '../../../common/comparator_types';
|
||||
|
||||
export async function executor(
|
||||
logger: Logger,
|
||||
core: CoreSetup,
|
||||
options: ExecutorOptions<EsQueryAlertParams>
|
||||
) {
|
||||
const esQueryAlert = isEsQueryAlert(options);
|
||||
const { alertId, name, services, params, state } = options;
|
||||
const { alertFactory, scopedClusterClient, searchSourceClient } = services;
|
||||
const currentTimestamp = new Date().toISOString();
|
||||
const publicBaseUrl = core.http.basePath.publicBaseUrl ?? '';
|
||||
|
||||
const compareFn = ComparatorFns.get(params.thresholdComparator);
|
||||
if (compareFn == null) {
|
||||
throw new Error(getInvalidComparatorError(params.thresholdComparator));
|
||||
}
|
||||
let latestTimestamp: string | undefined = tryToParseAsDate(state.latestTimestamp);
|
||||
|
||||
// During each alert execution, we run the configured query, get a hit count
|
||||
// (hits.total) and retrieve up to params.size hits. We
|
||||
// evaluate the threshold condition using the value of hits.total. If the threshold
|
||||
// condition is met, the hits are counted toward the query match and we update
|
||||
// the alert state with the timestamp of the latest hit. In the next execution
|
||||
// of the alert, the latestTimestamp will be used to gate the query in order to
|
||||
// avoid counting a document multiple times.
|
||||
|
||||
const { numMatches, searchResult, dateStart, dateEnd } = esQueryAlert
|
||||
? await fetchEsQuery(alertId, name, params as OnlyEsQueryAlertParams, latestTimestamp, {
|
||||
scopedClusterClient,
|
||||
logger,
|
||||
})
|
||||
: await fetchSearchSourceQuery(
|
||||
alertId,
|
||||
params as OnlySearchSourceAlertParams,
|
||||
latestTimestamp,
|
||||
{
|
||||
searchSourceClient,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
// apply the alert condition
|
||||
const conditionMet = compareFn(numMatches, params.threshold);
|
||||
|
||||
if (conditionMet) {
|
||||
const base = publicBaseUrl;
|
||||
const link = esQueryAlert
|
||||
? `${base}/app/management/insightsAndAlerting/triggersActions/rule/${alertId}`
|
||||
: `${base}/app/discover#/viewAlert/${alertId}?from=${dateStart}&to=${dateEnd}&checksum=${getChecksum(
|
||||
params
|
||||
)}`;
|
||||
|
||||
const conditions = getContextConditionsDescription(
|
||||
params.thresholdComparator,
|
||||
params.threshold
|
||||
);
|
||||
const baseContext: EsQueryAlertActionContext = {
|
||||
title: name,
|
||||
date: currentTimestamp,
|
||||
value: numMatches,
|
||||
conditions,
|
||||
hits: searchResult.hits.hits,
|
||||
link,
|
||||
};
|
||||
|
||||
const actionContext = addMessages(options, baseContext, params);
|
||||
const alertInstance = alertFactory.create(ConditionMetAlertInstanceId);
|
||||
alertInstance
|
||||
// store the params we would need to recreate the query that led to this alert instance
|
||||
.replaceState({ latestTimestamp, dateStart, dateEnd })
|
||||
.scheduleActions(ActionGroupId, actionContext);
|
||||
|
||||
// update the timestamp based on the current search results
|
||||
const firstValidTimefieldSort = getValidTimefieldSort(
|
||||
searchResult.hits.hits.find((hit) => getValidTimefieldSort(hit.sort))?.sort
|
||||
);
|
||||
if (firstValidTimefieldSort) {
|
||||
latestTimestamp = firstValidTimefieldSort;
|
||||
}
|
||||
}
|
||||
|
||||
return { latestTimestamp };
|
||||
}
|
||||
|
||||
function getInvalidWindowSizeError(windowValue: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', {
|
||||
defaultMessage: 'invalid format for windowSize: "{windowValue}"',
|
||||
values: {
|
||||
windowValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getInvalidQueryError(query: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
|
||||
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
|
||||
values: {
|
||||
query,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getSearchParams(queryParams: OnlyEsQueryAlertParams) {
|
||||
const date = Date.now();
|
||||
const { esQuery, timeWindowSize, timeWindowUnit } = queryParams;
|
||||
|
||||
let parsedQuery;
|
||||
try {
|
||||
parsedQuery = JSON.parse(esQuery);
|
||||
} catch (err) {
|
||||
throw new Error(getInvalidQueryError(esQuery));
|
||||
}
|
||||
|
||||
if (parsedQuery && !parsedQuery.query) {
|
||||
throw new Error(getInvalidQueryError(esQuery));
|
||||
}
|
||||
|
||||
const window = `${timeWindowSize}${timeWindowUnit}`;
|
||||
let timeWindow: number;
|
||||
try {
|
||||
timeWindow = parseDuration(window);
|
||||
} catch (err) {
|
||||
throw new Error(getInvalidWindowSizeError(window));
|
||||
}
|
||||
|
||||
const dateStart = new Date(date - timeWindow).toISOString();
|
||||
const dateEnd = new Date(date).toISOString();
|
||||
|
||||
return { parsedQuery, dateStart, dateEnd };
|
||||
}
|
||||
|
||||
export function getValidTimefieldSort(
|
||||
sortValues: Array<string | number | null> = []
|
||||
): undefined | string {
|
||||
for (const sortValue of sortValues) {
|
||||
const sortDate = tryToParseAsDate(sortValue);
|
||||
if (sortDate) {
|
||||
return sortDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function tryToParseAsDate(sortValue?: string | number | null): undefined | string {
|
||||
const sortDate = typeof sortValue === 'string' ? Date.parse(sortValue) : sortValue;
|
||||
if (sortDate && !isNaN(sortDate)) {
|
||||
return new Date(sortDate).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
export function isEsQueryAlert(options: ExecutorOptions<EsQueryAlertParams>) {
|
||||
return options.params.searchType !== 'searchSource';
|
||||
}
|
||||
|
||||
export function getChecksum(params: EsQueryAlertParams) {
|
||||
return sha256.create().update(JSON.stringify(params));
|
||||
}
|
||||
|
||||
export function getInvalidComparatorError(comparator: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', {
|
||||
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
|
||||
values: {
|
||||
comparator,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getContextConditionsDescription(comparator: Comparator, threshold: number[]) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription', {
|
||||
defaultMessage: 'Number of matching documents is {thresholdComparator} {threshold}',
|
||||
values: {
|
||||
thresholdComparator: getHumanReadableComparator(comparator),
|
||||
threshold: threshold.join(' and '),
|
||||
},
|
||||
});
|
||||
}
|
|
@ -5,16 +5,17 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Logger } from 'src/core/server';
|
||||
import { CoreSetup, Logger } from 'src/core/server';
|
||||
import { AlertingSetup } from '../../types';
|
||||
import { getAlertType } from './alert_type';
|
||||
|
||||
interface RegisterParams {
|
||||
logger: Logger;
|
||||
alerting: AlertingSetup;
|
||||
core: CoreSetup;
|
||||
}
|
||||
|
||||
export function register(params: RegisterParams) {
|
||||
const { logger, alerting } = params;
|
||||
alerting.registerType(getAlertType(logger));
|
||||
const { logger, alerting, core } = params;
|
||||
alerting.registerType(getAlertType(logger, core));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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 { IScopedClusterClient, Logger } from 'kibana/server';
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { OnlyEsQueryAlertParams } from '../types';
|
||||
import { buildSortedEventsQuery } from '../../../../common/build_sorted_events_query';
|
||||
import { ES_QUERY_ID } from '../constants';
|
||||
import { getSearchParams } from './get_search_params';
|
||||
|
||||
/**
|
||||
* Fetching matching documents for a given alert from elasticsearch by a given index and query
|
||||
*/
|
||||
export async function fetchEsQuery(
|
||||
alertId: string,
|
||||
name: string,
|
||||
params: OnlyEsQueryAlertParams,
|
||||
timestamp: string | undefined,
|
||||
services: {
|
||||
scopedClusterClient: IScopedClusterClient;
|
||||
logger: Logger;
|
||||
}
|
||||
) {
|
||||
const { scopedClusterClient, logger } = services;
|
||||
const esClient = scopedClusterClient.asCurrentUser;
|
||||
const { parsedQuery, dateStart, dateEnd } = getSearchParams(params);
|
||||
|
||||
const filter = timestamp
|
||||
? {
|
||||
bool: {
|
||||
filter: [
|
||||
parsedQuery.query,
|
||||
{
|
||||
bool: {
|
||||
must_not: [
|
||||
{
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
[params.timeField]: {
|
||||
lte: timestamp,
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: parsedQuery.query;
|
||||
|
||||
const query = buildSortedEventsQuery({
|
||||
index: params.index,
|
||||
from: dateStart,
|
||||
to: dateEnd,
|
||||
filter,
|
||||
size: params.size,
|
||||
sortOrder: 'desc',
|
||||
searchAfterSortId: undefined,
|
||||
timeField: params.timeField,
|
||||
track_total_hits: true,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`es query alert ${ES_QUERY_ID}:${alertId} "${name}" query - ${JSON.stringify(query)}`
|
||||
);
|
||||
|
||||
const { body: searchResult } = await esClient.search(query, { meta: true });
|
||||
|
||||
logger.debug(
|
||||
` es query alert ${ES_QUERY_ID}:${alertId} "${name}" result - ${JSON.stringify(searchResult)}`
|
||||
);
|
||||
return {
|
||||
numMatches: (searchResult.hits.total as estypes.SearchTotalHits).value,
|
||||
searchResult,
|
||||
dateStart,
|
||||
dateEnd,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* 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 { OnlySearchSourceAlertParams } from '../types';
|
||||
import { createSearchSourceMock } from 'src/plugins/data/common/search/search_source/mocks';
|
||||
import { updateSearchSource } from './fetch_search_source_query';
|
||||
import { stubbedSavedObjectIndexPattern } from '../../../../../../../src/plugins/data_views/common/data_view.stub';
|
||||
import { DataView } from '../../../../../../../src/plugins/data_views/common';
|
||||
import { fieldFormatsMock } from '../../../../../../../src/plugins/field_formats/common/mocks';
|
||||
import { Comparator } from '../../../../common/comparator_types';
|
||||
|
||||
const createDataView = () => {
|
||||
const id = 'test-id';
|
||||
const {
|
||||
type,
|
||||
version,
|
||||
attributes: { timeFieldName, fields, title },
|
||||
} = stubbedSavedObjectIndexPattern(id);
|
||||
|
||||
return new DataView({
|
||||
spec: { id, type, version, timeFieldName, fields: JSON.parse(fields), title },
|
||||
fieldFormats: fieldFormatsMock,
|
||||
shortDotsEnable: false,
|
||||
metaFields: ['_id', '_type', '_score'],
|
||||
});
|
||||
};
|
||||
|
||||
const defaultParams: OnlySearchSourceAlertParams = {
|
||||
size: 100,
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: Comparator.LT,
|
||||
threshold: [0],
|
||||
searchConfiguration: {},
|
||||
searchType: 'searchSource',
|
||||
};
|
||||
|
||||
describe('fetchSearchSourceQuery', () => {
|
||||
describe('updateSearchSource', () => {
|
||||
const dataViewMock = createDataView();
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const fakeNow = new Date('2020-02-09T23:15:41.941Z');
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetAllMocks();
|
||||
global.Date.now = jest.fn(() => fakeNow.getTime());
|
||||
});
|
||||
|
||||
it('without latest timestamp', async () => {
|
||||
const params = { ...defaultParams, thresholdComparator: Comparator.GT_OR_EQ, threshold: [3] };
|
||||
|
||||
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
|
||||
|
||||
const { searchSource, dateStart, dateEnd } = updateSearchSource(
|
||||
searchSourceInstance,
|
||||
params,
|
||||
undefined
|
||||
);
|
||||
const searchRequest = searchSource.getSearchRequestBody();
|
||||
expect(searchRequest.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"time": Object {
|
||||
"format": "strict_date_optional_time",
|
||||
"gte": "2020-02-09T23:10:41.941Z",
|
||||
"lte": "2020-02-09T23:15:41.941Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(dateStart).toMatch('2020-02-09T23:10:41.941Z');
|
||||
expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z');
|
||||
});
|
||||
|
||||
it('with latest timestamp in between the given time range ', async () => {
|
||||
const params = { ...defaultParams, thresholdComparator: Comparator.GT_OR_EQ, threshold: [3] };
|
||||
|
||||
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
|
||||
|
||||
const { searchSource } = updateSearchSource(
|
||||
searchSourceInstance,
|
||||
params,
|
||||
'2020-02-09T23:12:41.941Z'
|
||||
);
|
||||
const searchRequest = searchSource.getSearchRequestBody();
|
||||
expect(searchRequest.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"time": Object {
|
||||
"format": "strict_date_optional_time",
|
||||
"gte": "2020-02-09T23:10:41.941Z",
|
||||
"lte": "2020-02-09T23:15:41.941Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"range": Object {
|
||||
"time": Object {
|
||||
"gt": "2020-02-09T23:12:41.941Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('with latest timestamp in before the given time range ', async () => {
|
||||
const params = { ...defaultParams, thresholdComparator: Comparator.GT_OR_EQ, threshold: [3] };
|
||||
|
||||
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
|
||||
|
||||
const { searchSource } = updateSearchSource(
|
||||
searchSourceInstance,
|
||||
params,
|
||||
'2020-01-09T22:12:41.941Z'
|
||||
);
|
||||
const searchRequest = searchSource.getSearchRequestBody();
|
||||
expect(searchRequest.query).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"range": Object {
|
||||
"time": Object {
|
||||
"format": "strict_date_optional_time",
|
||||
"gte": "2020-02-09T23:10:41.941Z",
|
||||
"lte": "2020-02-09T23:15:41.941Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"must": Array [],
|
||||
"must_not": Array [],
|
||||
"should": Array [],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* 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 { buildRangeFilter, Filter } from '@kbn/es-query';
|
||||
import { Logger } from 'kibana/server';
|
||||
import { OnlySearchSourceAlertParams } from '../types';
|
||||
import {
|
||||
getTime,
|
||||
ISearchSource,
|
||||
ISearchStartSearchSource,
|
||||
SortDirection,
|
||||
} from '../../../../../../../src/plugins/data/common';
|
||||
|
||||
export async function fetchSearchSourceQuery(
|
||||
alertId: string,
|
||||
params: OnlySearchSourceAlertParams,
|
||||
latestTimestamp: string | undefined,
|
||||
services: {
|
||||
logger: Logger;
|
||||
searchSourceClient: Promise<ISearchStartSearchSource>;
|
||||
}
|
||||
) {
|
||||
const { logger, searchSourceClient } = services;
|
||||
const client = await searchSourceClient;
|
||||
const initialSearchSource = await client.create(params.searchConfiguration);
|
||||
|
||||
const { searchSource, dateStart, dateEnd } = updateSearchSource(
|
||||
initialSearchSource,
|
||||
params,
|
||||
latestTimestamp
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`search source query alert (${alertId}) query: ${JSON.stringify(
|
||||
searchSource.getSearchRequestBody()
|
||||
)}`
|
||||
);
|
||||
|
||||
const searchResult = await searchSource.fetch();
|
||||
|
||||
return {
|
||||
numMatches: Number(searchResult.hits.total),
|
||||
searchResult,
|
||||
dateStart,
|
||||
dateEnd,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSearchSource(
|
||||
searchSource: ISearchSource,
|
||||
params: OnlySearchSourceAlertParams,
|
||||
latestTimestamp: string | undefined
|
||||
) {
|
||||
const index = searchSource.getField('index');
|
||||
|
||||
const timeFieldName = index?.timeFieldName;
|
||||
if (!timeFieldName) {
|
||||
throw new Error('Invalid data view without timeFieldName.');
|
||||
}
|
||||
|
||||
searchSource.setField('size', params.size);
|
||||
|
||||
const timerangeFilter = getTime(index, {
|
||||
from: `now-${params.timeWindowSize}${params.timeWindowUnit}`,
|
||||
to: 'now',
|
||||
});
|
||||
const dateStart = timerangeFilter?.query.range[timeFieldName].gte;
|
||||
const dateEnd = timerangeFilter?.query.range[timeFieldName].lte;
|
||||
const filters = [timerangeFilter];
|
||||
|
||||
if (latestTimestamp && latestTimestamp > dateStart) {
|
||||
// add additional filter for documents with a timestamp greater then
|
||||
// the timestamp of the previous run, so that those documents are not counted twice
|
||||
const field = index.fields.find((f) => f.name === timeFieldName);
|
||||
const addTimeRangeField = buildRangeFilter(field!, { gt: latestTimestamp }, index);
|
||||
filters.push(addTimeRangeField);
|
||||
}
|
||||
const searchSourceChild = searchSource.createChild();
|
||||
searchSourceChild.setField('filter', filters as Filter[]);
|
||||
searchSourceChild.setField('sort', [{ [timeFieldName]: SortDirection.desc }]);
|
||||
return {
|
||||
searchSource: searchSourceChild,
|
||||
dateStart,
|
||||
dateEnd,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { OnlyEsQueryAlertParams } from '../types';
|
||||
import { parseDuration } from '../../../../../alerting/common';
|
||||
|
||||
export function getSearchParams(queryParams: OnlyEsQueryAlertParams) {
|
||||
const date = Date.now();
|
||||
const { esQuery, timeWindowSize, timeWindowUnit } = queryParams;
|
||||
|
||||
let parsedQuery;
|
||||
try {
|
||||
parsedQuery = JSON.parse(esQuery);
|
||||
} catch (err) {
|
||||
throw new Error(getInvalidQueryError(esQuery));
|
||||
}
|
||||
|
||||
if (parsedQuery && !parsedQuery.query) {
|
||||
throw new Error(getInvalidQueryError(esQuery));
|
||||
}
|
||||
|
||||
const window = `${timeWindowSize}${timeWindowUnit}`;
|
||||
let timeWindow: number;
|
||||
try {
|
||||
timeWindow = parseDuration(window);
|
||||
} catch (err) {
|
||||
throw new Error(getInvalidWindowSizeError(window));
|
||||
}
|
||||
|
||||
const dateStart = new Date(date - timeWindow).toISOString();
|
||||
const dateEnd = new Date(date).toISOString();
|
||||
|
||||
return { parsedQuery, dateStart, dateEnd };
|
||||
}
|
||||
|
||||
function getInvalidWindowSizeError(windowValue: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', {
|
||||
defaultMessage: 'invalid format for windowSize: "{windowValue}"',
|
||||
values: {
|
||||
windowValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getInvalidQueryError(query: string) {
|
||||
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
|
||||
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
|
||||
values: {
|
||||
query,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 { AlertExecutorOptions, AlertTypeParams } from '../../types';
|
||||
import { ActionContext } from './action_context';
|
||||
import { EsQueryAlertParams, EsQueryAlertState } from './alert_type_params';
|
||||
import { ActionGroupId } from './constants';
|
||||
|
||||
export type OnlyEsQueryAlertParams = Omit<EsQueryAlertParams, 'searchConfiguration' | 'searchType'>;
|
||||
|
||||
export type OnlySearchSourceAlertParams = Omit<
|
||||
EsQueryAlertParams,
|
||||
'esQuery' | 'index' | 'timeField'
|
||||
> & {
|
||||
searchType: 'searchSource';
|
||||
};
|
||||
|
||||
export type ExecutorOptions<P extends AlertTypeParams> = AlertExecutorOptions<
|
||||
P,
|
||||
EsQueryAlertState,
|
||||
{},
|
||||
ActionContext,
|
||||
typeof ActionGroupId
|
||||
>;
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Logger } from 'src/core/server';
|
||||
import { CoreSetup, Logger } from 'src/core/server';
|
||||
import { AlertingSetup, StackAlertsStartDeps } from '../types';
|
||||
import { register as registerIndexThreshold } from './index_threshold';
|
||||
import { register as registerGeoContainment } from './geo_containment';
|
||||
|
@ -14,6 +14,7 @@ interface RegisterAlertTypesParams {
|
|||
logger: Logger;
|
||||
data: Promise<StackAlertsStartDeps['triggersActionsUi']['data']>;
|
||||
alerting: AlertingSetup;
|
||||
core: CoreSetup;
|
||||
}
|
||||
|
||||
export function registerBuiltInAlertTypes(params: RegisterAlertTypesParams) {
|
||||
|
|
|
@ -13,6 +13,7 @@ import { getAlertType, ActionGroupId } from './alert_type';
|
|||
import { ActionContext } from './action_context';
|
||||
import { Params } from './alert_type_params';
|
||||
import { AlertServicesMock, alertsMock } from '../../../../alerting/server/mocks';
|
||||
import { Comparator } from '../../../common/comparator_types';
|
||||
|
||||
describe('alertType', () => {
|
||||
const logger = loggingSystemMock.create().get();
|
||||
|
@ -118,7 +119,7 @@ describe('alertType', () => {
|
|||
groupBy: 'all',
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '<',
|
||||
thresholdComparator: Comparator.LT,
|
||||
threshold: [0],
|
||||
};
|
||||
|
||||
|
@ -136,7 +137,7 @@ describe('alertType', () => {
|
|||
groupBy: 'all',
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '>',
|
||||
thresholdComparator: Comparator.GT,
|
||||
threshold: [0],
|
||||
};
|
||||
|
||||
|
@ -163,7 +164,7 @@ describe('alertType', () => {
|
|||
groupBy: 'all',
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '<',
|
||||
thresholdComparator: Comparator.LT,
|
||||
threshold: [1],
|
||||
};
|
||||
|
||||
|
@ -225,7 +226,7 @@ describe('alertType', () => {
|
|||
groupBy: 'all',
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '<',
|
||||
thresholdComparator: Comparator.LT,
|
||||
threshold: [1],
|
||||
};
|
||||
|
||||
|
@ -291,7 +292,7 @@ describe('alertType', () => {
|
|||
groupBy: 'all',
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '<',
|
||||
thresholdComparator: Comparator.LT,
|
||||
threshold: [1],
|
||||
};
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { ParamsSchema, Params } from './alert_type_params';
|
|||
import { ObjectType, TypeOf } from '@kbn/config-schema';
|
||||
import type { Writable } from '@kbn/utility-types';
|
||||
import { CoreQueryParams, MAX_GROUPS } from '../../../../triggers_actions_ui/server';
|
||||
import { Comparator } from '../../../common/comparator_types';
|
||||
|
||||
const DefaultParams: Writable<Partial<Params>> = {
|
||||
index: 'index-name',
|
||||
|
@ -17,7 +18,7 @@ const DefaultParams: Writable<Partial<Params>> = {
|
|||
groupBy: 'all',
|
||||
timeWindowSize: 5,
|
||||
timeWindowUnit: 'm',
|
||||
thresholdComparator: '>',
|
||||
thresholdComparator: Comparator.GT,
|
||||
threshold: [0],
|
||||
};
|
||||
|
||||
|
|
|
@ -6,12 +6,13 @@
|
|||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { ComparatorFnNames } from '../lib';
|
||||
import { schema, Type, TypeOf } from '@kbn/config-schema';
|
||||
import {
|
||||
CoreQueryParamsSchemaProperties,
|
||||
validateCoreQueryBody,
|
||||
} from '../../../../triggers_actions_ui/server';
|
||||
import { ComparatorFnNames } from '../lib';
|
||||
import { Comparator } from '../../../common/comparator_types';
|
||||
|
||||
// alert type parameters
|
||||
|
||||
|
@ -21,7 +22,7 @@ export const ParamsSchema = schema.object(
|
|||
{
|
||||
...CoreQueryParamsSchemaProperties,
|
||||
// the comparison function to use to determine if the threshold as been met
|
||||
thresholdComparator: schema.string({ validate: validateComparator }),
|
||||
thresholdComparator: schema.string({ validate: validateComparator }) as Type<Comparator>,
|
||||
// the values to use as the threshold; `between` and `notBetween` require
|
||||
// two values, the others require one.
|
||||
threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }),
|
||||
|
@ -52,8 +53,8 @@ function validateParams(anyParams: unknown): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
export function validateComparator(comparator: string): string | undefined {
|
||||
if (ComparatorFnNames.has(comparator)) return;
|
||||
function validateComparator(comparator: string): string | undefined {
|
||||
if (ComparatorFnNames.has(comparator as Comparator)) return;
|
||||
|
||||
return i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', {
|
||||
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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 { Comparator } from '../../../common/comparator_types';
|
||||
|
||||
export type ComparatorFn = (value: number, threshold: number[]) => boolean;
|
||||
|
||||
const humanReadableComparators = new Map<Comparator, string>([
|
||||
[Comparator.LT, 'less than'],
|
||||
[Comparator.LT_OR_EQ, 'less than or equal to'],
|
||||
[Comparator.GT_OR_EQ, 'greater than or equal to'],
|
||||
[Comparator.GT, 'greater than'],
|
||||
[Comparator.BETWEEN, 'between'],
|
||||
[Comparator.NOT_BETWEEN, 'not between'],
|
||||
]);
|
||||
|
||||
export const ComparatorFns = new Map<Comparator, ComparatorFn>([
|
||||
[Comparator.LT, (value: number, threshold: number[]) => value < threshold[0]],
|
||||
[Comparator.LT_OR_EQ, (value: number, threshold: number[]) => value <= threshold[0]],
|
||||
[Comparator.GT_OR_EQ, (value: number, threshold: number[]) => value >= threshold[0]],
|
||||
[Comparator.GT, (value: number, threshold: number[]) => value > threshold[0]],
|
||||
[
|
||||
Comparator.BETWEEN,
|
||||
(value: number, threshold: number[]) => value >= threshold[0] && value <= threshold[1],
|
||||
],
|
||||
[
|
||||
Comparator.NOT_BETWEEN,
|
||||
(value: number, threshold: number[]) => value < threshold[0] || value > threshold[1],
|
||||
],
|
||||
]);
|
||||
|
||||
export const ComparatorFnNames = new Set(ComparatorFns.keys());
|
||||
|
||||
export function getHumanReadableComparator(comparator: Comparator) {
|
||||
return humanReadableComparators.has(comparator)
|
||||
? humanReadableComparators.get(comparator)
|
||||
: comparator;
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
enum Comparator {
|
||||
GT = '>',
|
||||
LT = '<',
|
||||
GT_OR_EQ = '>=',
|
||||
LT_OR_EQ = '<=',
|
||||
BETWEEN = 'between',
|
||||
NOT_BETWEEN = 'notBetween',
|
||||
}
|
||||
|
||||
const humanReadableComparators = new Map<string, string>([
|
||||
[Comparator.LT, 'less than'],
|
||||
[Comparator.LT_OR_EQ, 'less than or equal to'],
|
||||
[Comparator.GT_OR_EQ, 'greater than or equal to'],
|
||||
[Comparator.GT, 'greater than'],
|
||||
[Comparator.BETWEEN, 'between'],
|
||||
[Comparator.NOT_BETWEEN, 'not between'],
|
||||
]);
|
||||
|
||||
export const ComparatorFns = getComparatorFns();
|
||||
export const ComparatorFnNames = new Set(ComparatorFns.keys());
|
||||
|
||||
type ComparatorFn = (value: number, threshold: number[]) => boolean;
|
||||
|
||||
function getComparatorFns(): Map<string, ComparatorFn> {
|
||||
const fns: Record<string, ComparatorFn> = {
|
||||
[Comparator.LT]: (value: number, threshold: number[]) => value < threshold[0],
|
||||
[Comparator.LT_OR_EQ]: (value: number, threshold: number[]) => value <= threshold[0],
|
||||
[Comparator.GT_OR_EQ]: (value: number, threshold: number[]) => value >= threshold[0],
|
||||
[Comparator.GT]: (value: number, threshold: number[]) => value > threshold[0],
|
||||
[Comparator.BETWEEN]: (value: number, threshold: number[]) =>
|
||||
value >= threshold[0] && value <= threshold[1],
|
||||
[Comparator.NOT_BETWEEN]: (value: number, threshold: number[]) =>
|
||||
value < threshold[0] || value > threshold[1],
|
||||
};
|
||||
|
||||
const result = new Map<string, ComparatorFn>();
|
||||
for (const key of Object.keys(fns)) {
|
||||
result.set(key, fns[key]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getHumanReadableComparator(comparator: string) {
|
||||
return humanReadableComparators.has(comparator)
|
||||
? humanReadableComparators.get(comparator)
|
||||
: comparator;
|
||||
}
|
|
@ -5,4 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { ComparatorFns, ComparatorFnNames, getHumanReadableComparator } from './comparator_types';
|
||||
export { ComparatorFns, ComparatorFnNames, getHumanReadableComparator } from './comparator';
|
||||
|
|
|
@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import { KibanaFeatureConfig } from '../../../plugins/features/common';
|
||||
import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type';
|
||||
import { GEO_CONTAINMENT_ID as GeoContainment } from './alert_types/geo_containment/alert_type';
|
||||
import { ES_QUERY_ID as ElasticsearchQuery } from './alert_types/es_query/alert_type';
|
||||
import { ES_QUERY_ID as ElasticsearchQuery } from './alert_types/es_query/constants';
|
||||
import { STACK_ALERTS_FEATURE_ID } from '../common';
|
||||
import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server';
|
||||
import { TRANSFORM_RULE_TYPE } from '../../transform/common';
|
||||
|
|
|
@ -29,6 +29,7 @@ export class AlertingBuiltinsPlugin
|
|||
.getStartServices()
|
||||
.then(async ([, { triggersActionsUi }]) => triggersActionsUi.data),
|
||||
alerting,
|
||||
core,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ export type {
|
|||
RuleType,
|
||||
RuleParamsAndRefs,
|
||||
AlertExecutorOptions,
|
||||
AlertTypeParams,
|
||||
} from '../../alerting/server';
|
||||
import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
|
||||
|
||||
|
|
|
@ -23329,7 +23329,6 @@
|
|||
"xpack.stackAlerts.esQuery.actionVariableContextTitleLabel": "Titre pour l'alerte.",
|
||||
"xpack.stackAlerts.esQuery.actionVariableContextValueLabel": "Valeur ayant rempli la condition de seuil.",
|
||||
"xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription": "Le nombre de documents correspondants est {thresholdComparator} {threshold}",
|
||||
"xpack.stackAlerts.esQuery.alertTypeContextMessageDescription": "l'alerte \"{name}\" est active :\n\n- Valeur : {value}\n- Conditions remplies : {conditions} sur {window}\n- Horodatage : {date}",
|
||||
"xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle": "l'alerte \"{name}\" correspond à la recherche",
|
||||
"xpack.stackAlerts.esQuery.alertTypeTitle": "Recherche Elasticsearch",
|
||||
"xpack.stackAlerts.esQuery.invalidComparatorErrorMessage": "thresholdComparator spécifié non valide : {comparator}",
|
||||
|
|
|
@ -26653,7 +26653,6 @@
|
|||
"xpack.stackAlerts.esQuery.actionVariableContextTitleLabel": "アラートのタイトル。",
|
||||
"xpack.stackAlerts.esQuery.actionVariableContextValueLabel": "しきい値条件を満たした値。",
|
||||
"xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription": "一致するドキュメント数は{thresholdComparator} {threshold}です",
|
||||
"xpack.stackAlerts.esQuery.alertTypeContextMessageDescription": "アラート'{name}'は有効です。\n\n- 値:{value}\n- 条件が満たされました:{window} の {conditions}\n- タイムスタンプ:{date}",
|
||||
"xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle": "アラート'{name}'はクエリと一致しました",
|
||||
"xpack.stackAlerts.esQuery.alertTypeTitle": "Elasticsearch クエリ",
|
||||
"xpack.stackAlerts.esQuery.invalidComparatorErrorMessage": "無効な thresholdComparator が指定されました:{comparator}",
|
||||
|
|
|
@ -26682,7 +26682,6 @@
|
|||
"xpack.stackAlerts.esQuery.actionVariableContextTitleLabel": "告警的标题。",
|
||||
"xpack.stackAlerts.esQuery.actionVariableContextValueLabel": "满足阈值条件的值。",
|
||||
"xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription": "匹配文档的数目{thresholdComparator} {threshold}",
|
||||
"xpack.stackAlerts.esQuery.alertTypeContextMessageDescription": "告警“{name}”处于活动状态:\n\n- 值:{value}\n- 满足的条件:{conditions} 超过 {window}\n- 时间戳:{date}",
|
||||
"xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle": "告警“{name}”已匹配查询",
|
||||
"xpack.stackAlerts.esQuery.alertTypeTitle": "Elasticsearch 查询",
|
||||
"xpack.stackAlerts.esQuery.invalidComparatorErrorMessage": "指定的 thresholdComparator 无效:{comparator}",
|
||||
|
|
|
@ -107,7 +107,8 @@ export class ESTestIndexTool {
|
|||
return await this.retry.try(async () => {
|
||||
const searchResult = await this.search(source, reference);
|
||||
// @ts-expect-error doesn't handle total: number
|
||||
if (searchResult.body.hits.total.value < numDocs) {
|
||||
const value = searchResult.body.hits.total.value?.value || searchResult.body.hits.total.value;
|
||||
if (value < numDocs) {
|
||||
// @ts-expect-error doesn't handle total: number
|
||||
throw new Error(`Expected ${numDocs} but received ${searchResult.body.hits.total.value}.`);
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ const ES_GROUPS_TO_WRITE = 3;
|
|||
export default function alertTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const retry = getService('retry');
|
||||
const indexPatterns = getService('indexPatterns');
|
||||
const es = getService('es');
|
||||
const esTestIndexTool = new ESTestIndexTool(es, retry);
|
||||
const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME);
|
||||
|
@ -61,180 +62,357 @@ export default function alertTests({ getService }: FtrProviderContext) {
|
|||
await esTestIndexToolOutput.destroy();
|
||||
});
|
||||
|
||||
it('runs correctly: threshold on hit count < >', async () => {
|
||||
// write documents from now to the future end date in groups
|
||||
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
|
||||
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [0],
|
||||
});
|
||||
|
||||
await createAlert({
|
||||
name: 'always fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
thresholdComparator: '>',
|
||||
threshold: [-1],
|
||||
});
|
||||
|
||||
const docs = await waitForDocs(2);
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
const doc = docs[i];
|
||||
const { previousTimestamp, hits } = doc._source;
|
||||
const { name, title, message } = doc._source.params;
|
||||
|
||||
expect(name).to.be('always fire');
|
||||
expect(title).to.be(`alert 'always fire' matched query`);
|
||||
const messagePattern =
|
||||
/alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
expect(message).to.match(messagePattern);
|
||||
expect(hits).not.to.be.empty();
|
||||
|
||||
// during the first execution, the latestTimestamp value should be empty
|
||||
// since this alert always fires, the latestTimestamp value should be updated each execution
|
||||
if (!i) {
|
||||
expect(previousTimestamp).to.be.empty();
|
||||
} else {
|
||||
expect(previousTimestamp).not.to.be.empty();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('runs correctly: use epoch millis - threshold on hit count < >', async () => {
|
||||
// write documents from now to the future end date in groups
|
||||
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
|
||||
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [0],
|
||||
timeField: 'date_epoch_millis',
|
||||
});
|
||||
|
||||
await createAlert({
|
||||
name: 'always fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
thresholdComparator: '>',
|
||||
threshold: [-1],
|
||||
timeField: 'date_epoch_millis',
|
||||
});
|
||||
|
||||
const docs = await waitForDocs(2);
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
const doc = docs[i];
|
||||
const { previousTimestamp, hits } = doc._source;
|
||||
const { name, title, message } = doc._source.params;
|
||||
|
||||
expect(name).to.be('always fire');
|
||||
expect(title).to.be(`alert 'always fire' matched query`);
|
||||
const messagePattern =
|
||||
/alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
expect(message).to.match(messagePattern);
|
||||
expect(hits).not.to.be.empty();
|
||||
|
||||
// during the first execution, the latestTimestamp value should be empty
|
||||
// since this alert always fires, the latestTimestamp value should be updated each execution
|
||||
if (!i) {
|
||||
expect(previousTimestamp).to.be.empty();
|
||||
} else {
|
||||
expect(previousTimestamp).not.to.be.empty();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('runs correctly with query: threshold on hit count < >', async () => {
|
||||
// write documents from now to the future end date in groups
|
||||
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
|
||||
|
||||
const rangeQuery = (rangeThreshold: number) => {
|
||||
return {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
testedValue: {
|
||||
gte: rangeThreshold,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
[
|
||||
'esQuery',
|
||||
async () => {
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [0],
|
||||
});
|
||||
await createAlert({
|
||||
name: 'always fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
thresholdComparator: '>',
|
||||
threshold: [-1],
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
[
|
||||
'searchSource',
|
||||
async () => {
|
||||
const esTestDataView = await indexPatterns.create(
|
||||
{ title: ES_TEST_INDEX_NAME, timeFieldName: 'date' },
|
||||
{ override: true },
|
||||
getUrlPrefix(Spaces.space1.id)
|
||||
);
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [0],
|
||||
searchType: 'searchSource',
|
||||
searchConfiguration: {
|
||||
query: {
|
||||
query: '',
|
||||
language: 'kuery',
|
||||
},
|
||||
index: esTestDataView.id,
|
||||
filter: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
await createAlert({
|
||||
name: 'always fire',
|
||||
size: 100,
|
||||
thresholdComparator: '>',
|
||||
threshold: [-1],
|
||||
searchType: 'searchSource',
|
||||
searchConfiguration: {
|
||||
query: {
|
||||
query: '',
|
||||
language: 'kuery',
|
||||
},
|
||||
index: esTestDataView.id,
|
||||
filter: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
].forEach(([searchType, initData]) =>
|
||||
it(`runs correctly: threshold on hit count < > for ${searchType} search type`, async () => {
|
||||
// write documents from now to the future end date in groups
|
||||
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
|
||||
await initData();
|
||||
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
esQuery: JSON.stringify(rangeQuery(ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE + 1)),
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [-1],
|
||||
});
|
||||
const docs = await waitForDocs(2);
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
const doc = docs[i];
|
||||
const { previousTimestamp, hits } = doc._source;
|
||||
const { name, title, message } = doc._source.params;
|
||||
|
||||
await createAlert({
|
||||
name: 'fires once',
|
||||
esQuery: JSON.stringify(
|
||||
rangeQuery(Math.floor((ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE) / 2))
|
||||
),
|
||||
size: 100,
|
||||
thresholdComparator: '>=',
|
||||
threshold: [0],
|
||||
});
|
||||
expect(name).to.be('always fire');
|
||||
expect(title).to.be(`alert 'always fire' matched query`);
|
||||
const messagePattern =
|
||||
/alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
expect(message).to.match(messagePattern);
|
||||
expect(hits).not.to.be.empty();
|
||||
|
||||
const docs = await waitForDocs(1);
|
||||
for (const doc of docs) {
|
||||
const { previousTimestamp, hits } = doc._source;
|
||||
const { name, title, message } = doc._source.params;
|
||||
|
||||
expect(name).to.be('fires once');
|
||||
expect(title).to.be(`alert 'fires once' matched query`);
|
||||
const messagePattern =
|
||||
/alert 'fires once' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
expect(message).to.match(messagePattern);
|
||||
expect(hits).not.to.be.empty();
|
||||
expect(previousTimestamp).to.be.empty();
|
||||
}
|
||||
});
|
||||
|
||||
it('runs correctly: no matches', async () => {
|
||||
await createAlert({
|
||||
name: 'always fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [1],
|
||||
});
|
||||
|
||||
const docs = await waitForDocs(1);
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
const doc = docs[i];
|
||||
const { previousTimestamp, hits } = doc._source;
|
||||
const { name, title, message } = doc._source.params;
|
||||
|
||||
expect(name).to.be('always fire');
|
||||
expect(title).to.be(`alert 'always fire' matched query`);
|
||||
const messagePattern =
|
||||
/alert 'always fire' is active:\n\n- Value: 0+\n- Conditions Met: Number of matching documents is less than 1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
expect(message).to.match(messagePattern);
|
||||
expect(hits).to.be.empty();
|
||||
|
||||
// during the first execution, the latestTimestamp value should be empty
|
||||
// since this alert always fires, the latestTimestamp value should be updated each execution
|
||||
if (!i) {
|
||||
expect(previousTimestamp).to.be.empty();
|
||||
} else {
|
||||
expect(previousTimestamp).not.to.be.empty();
|
||||
// during the first execution, the latestTimestamp value should be empty
|
||||
// since this alert always fires, the latestTimestamp value should be updated each execution
|
||||
if (!i) {
|
||||
expect(previousTimestamp).to.be.empty();
|
||||
} else {
|
||||
expect(previousTimestamp).not.to.be.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
[
|
||||
[
|
||||
'esQuery',
|
||||
async () => {
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [0],
|
||||
timeField: 'date_epoch_millis',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
});
|
||||
await createAlert({
|
||||
name: 'always fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
thresholdComparator: '>',
|
||||
threshold: [-1],
|
||||
timeField: 'date_epoch_millis',
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
[
|
||||
'searchSource',
|
||||
async () => {
|
||||
const esTestDataView = await indexPatterns.create(
|
||||
{ title: ES_TEST_INDEX_NAME, timeFieldName: 'date_epoch_millis' },
|
||||
{ override: true },
|
||||
getUrlPrefix(Spaces.space1.id)
|
||||
);
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [0],
|
||||
searchType: 'searchSource',
|
||||
searchConfiguration: {
|
||||
query: {
|
||||
query: '',
|
||||
language: 'kuery',
|
||||
},
|
||||
index: esTestDataView.id,
|
||||
filter: [],
|
||||
},
|
||||
});
|
||||
await createAlert({
|
||||
name: 'always fire',
|
||||
size: 100,
|
||||
thresholdComparator: '>',
|
||||
threshold: [-1],
|
||||
searchType: 'searchSource',
|
||||
searchConfiguration: {
|
||||
query: {
|
||||
query: '',
|
||||
language: 'kuery',
|
||||
},
|
||||
index: esTestDataView.id,
|
||||
filter: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
].forEach(([searchType, initData]) =>
|
||||
it(`runs correctly: use epoch millis - threshold on hit count < > for ${searchType} search type`, async () => {
|
||||
// write documents from now to the future end date in groups
|
||||
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
|
||||
await initData();
|
||||
|
||||
const docs = await waitForDocs(2);
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
const doc = docs[i];
|
||||
const { previousTimestamp, hits } = doc._source;
|
||||
const { name, title, message } = doc._source.params;
|
||||
|
||||
expect(name).to.be('always fire');
|
||||
expect(title).to.be(`alert 'always fire' matched query`);
|
||||
const messagePattern =
|
||||
/alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
expect(message).to.match(messagePattern);
|
||||
expect(hits).not.to.be.empty();
|
||||
|
||||
// during the first execution, the latestTimestamp value should be empty
|
||||
// since this alert always fires, the latestTimestamp value should be updated each execution
|
||||
if (!i) {
|
||||
expect(previousTimestamp).to.be.empty();
|
||||
} else {
|
||||
expect(previousTimestamp).not.to.be.empty();
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
[
|
||||
[
|
||||
'esQuery',
|
||||
async () => {
|
||||
const rangeQuery = (rangeThreshold: number) => {
|
||||
return {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
testedValue: {
|
||||
gte: rangeThreshold,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
esQuery: JSON.stringify(rangeQuery(ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE + 1)),
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [-1],
|
||||
});
|
||||
await createAlert({
|
||||
name: 'fires once',
|
||||
esQuery: JSON.stringify(
|
||||
rangeQuery(Math.floor((ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE) / 2))
|
||||
),
|
||||
size: 100,
|
||||
thresholdComparator: '>=',
|
||||
threshold: [0],
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
[
|
||||
'searchSource',
|
||||
async () => {
|
||||
const esTestDataView = await indexPatterns.create(
|
||||
{ title: ES_TEST_INDEX_NAME, timeFieldName: 'date' },
|
||||
{ override: true },
|
||||
getUrlPrefix(Spaces.space1.id)
|
||||
);
|
||||
await createAlert({
|
||||
name: 'never fire',
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [-1],
|
||||
searchType: 'searchSource',
|
||||
searchConfiguration: {
|
||||
query: {
|
||||
query: `testedValue > ${ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE + 1}`,
|
||||
language: 'kuery',
|
||||
},
|
||||
index: esTestDataView.id,
|
||||
filter: [],
|
||||
},
|
||||
});
|
||||
await createAlert({
|
||||
name: 'fires once',
|
||||
size: 100,
|
||||
thresholdComparator: '>=',
|
||||
threshold: [0],
|
||||
searchType: 'searchSource',
|
||||
searchConfiguration: {
|
||||
query: {
|
||||
query: `testedValue > ${Math.floor(
|
||||
(ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE) / 2
|
||||
)}`,
|
||||
language: 'kuery',
|
||||
},
|
||||
index: esTestDataView.id,
|
||||
filter: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
].forEach(([searchType, initData]) =>
|
||||
it(`runs correctly with query: threshold on hit count < > for ${searchType}`, async () => {
|
||||
// write documents from now to the future end date in groups
|
||||
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
|
||||
await initData();
|
||||
|
||||
const docs = await waitForDocs(1);
|
||||
for (const doc of docs) {
|
||||
const { previousTimestamp, hits } = doc._source;
|
||||
const { name, title, message } = doc._source.params;
|
||||
|
||||
expect(name).to.be('fires once');
|
||||
expect(title).to.be(`alert 'fires once' matched query`);
|
||||
const messagePattern =
|
||||
/alert 'fires once' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
expect(message).to.match(messagePattern);
|
||||
expect(hits).not.to.be.empty();
|
||||
expect(previousTimestamp).to.be.empty();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
[
|
||||
[
|
||||
'esQuery',
|
||||
async () => {
|
||||
await createAlert({
|
||||
name: 'always fire',
|
||||
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [1],
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
[
|
||||
'searchSource',
|
||||
async () => {
|
||||
const esTestDataView = await indexPatterns.create(
|
||||
{ title: ES_TEST_INDEX_NAME, timeFieldName: 'date' },
|
||||
{ override: true },
|
||||
getUrlPrefix(Spaces.space1.id)
|
||||
);
|
||||
|
||||
await createAlert({
|
||||
name: 'always fire',
|
||||
size: 100,
|
||||
thresholdComparator: '<',
|
||||
threshold: [1],
|
||||
searchType: 'searchSource',
|
||||
searchConfiguration: {
|
||||
query: {
|
||||
query: '',
|
||||
language: 'kuery',
|
||||
},
|
||||
index: esTestDataView.id,
|
||||
filter: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
] as const,
|
||||
].forEach(([searchType, initData]) =>
|
||||
it(`runs correctly: no matches for ${searchType} search type`, async () => {
|
||||
await initData();
|
||||
|
||||
const docs = await waitForDocs(1);
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
const doc = docs[i];
|
||||
const { previousTimestamp, hits } = doc._source;
|
||||
const { name, title, message } = doc._source.params;
|
||||
|
||||
expect(name).to.be('always fire');
|
||||
expect(title).to.be(`alert 'always fire' matched query`);
|
||||
const messagePattern =
|
||||
/alert 'always fire' is active:\n\n- Value: 0+\n- Conditions Met: Number of matching documents is less than 1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
|
||||
expect(message).to.match(messagePattern);
|
||||
expect(hits).to.be.empty();
|
||||
|
||||
// during the first execution, the latestTimestamp value should be empty
|
||||
// since this alert always fires, the latestTimestamp value should be updated each execution
|
||||
if (!i) {
|
||||
expect(previousTimestamp).to.be.empty();
|
||||
} else {
|
||||
expect(previousTimestamp).not.to.be.empty();
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
async function createEsDocumentsInGroups(groups: number) {
|
||||
await createEsDocuments(
|
||||
|
@ -257,12 +435,14 @@ export default function alertTests({ getService }: FtrProviderContext) {
|
|||
|
||||
interface CreateAlertParams {
|
||||
name: string;
|
||||
timeField?: string;
|
||||
esQuery: string;
|
||||
size: number;
|
||||
thresholdComparator: string;
|
||||
threshold: number[];
|
||||
timeWindowSize?: number;
|
||||
esQuery?: string;
|
||||
timeField?: string;
|
||||
searchConfiguration?: unknown;
|
||||
searchType?: 'searchSource';
|
||||
}
|
||||
|
||||
async function createAlert(params: CreateAlertParams): Promise<string> {
|
||||
|
@ -288,6 +468,17 @@ export default function alertTests({ getService }: FtrProviderContext) {
|
|||
},
|
||||
};
|
||||
|
||||
const alertParams =
|
||||
params.searchType === 'searchSource'
|
||||
? {
|
||||
searchConfiguration: params.searchConfiguration,
|
||||
}
|
||||
: {
|
||||
index: [ES_TEST_INDEX_NAME],
|
||||
timeField: params.timeField || 'date',
|
||||
esQuery: params.esQuery,
|
||||
};
|
||||
|
||||
const { body: createdAlert } = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
|
@ -300,14 +491,13 @@ export default function alertTests({ getService }: FtrProviderContext) {
|
|||
actions: [action],
|
||||
notify_when: 'onActiveAlert',
|
||||
params: {
|
||||
index: [ES_TEST_INDEX_NAME],
|
||||
timeField: params.timeField || 'date',
|
||||
esQuery: params.esQuery,
|
||||
size: params.size,
|
||||
timeWindowSize: params.timeWindowSize || ALERT_INTERVAL_SECONDS * 5,
|
||||
timeWindowUnit: 's',
|
||||
thresholdComparator: params.thresholdComparator,
|
||||
threshold: params.threshold,
|
||||
searchType: params.searchType,
|
||||
...alertParams,
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
|
|
@ -11,8 +11,8 @@ import { buildUp, tearDown } from '..';
|
|||
// eslint-disable-next-line import/no-default-export
|
||||
export default function alertingTests({ loadTestFile, getService }: FtrProviderContext) {
|
||||
describe('Alerting', () => {
|
||||
before(async () => buildUp(getService));
|
||||
after(async () => tearDown(getService));
|
||||
before(async () => await buildUp(getService));
|
||||
after(async () => await tearDown(getService));
|
||||
|
||||
loadTestFile(require.resolve('./aggregate'));
|
||||
loadTestFile(require.resolve('./create'));
|
||||
|
|
14
x-pack/test/functional_with_es_ssl/apps/discover/index.ts
Normal file
14
x-pack/test/functional_with_es_ssl/apps/discover/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default ({ loadTestFile, getService }: FtrProviderContext) => {
|
||||
describe('Discover alerting', function () {
|
||||
this.tags('ciGroup6');
|
||||
loadTestFile(require.resolve('./search_source_alert'));
|
||||
});
|
||||
};
|
|
@ -0,0 +1,340 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { asyncForEach } from '@kbn/std';
|
||||
import { last } from 'lodash';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const log = getService('log');
|
||||
const es = getService('es');
|
||||
const monacoEditor = getService('monacoEditor');
|
||||
const PageObjects = getPageObjects([
|
||||
'settings',
|
||||
'common',
|
||||
'header',
|
||||
'discover',
|
||||
'timePicker',
|
||||
'dashboard',
|
||||
]);
|
||||
const deployment = getService('deployment');
|
||||
const dataGrid = getService('dataGrid');
|
||||
const browser = getService('browser');
|
||||
const retry = getService('retry');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const supertest = getService('supertest');
|
||||
const queryBar = getService('queryBar');
|
||||
const security = getService('security');
|
||||
|
||||
const SOURCE_DATA_INDEX = 'search-source-alert';
|
||||
const OUTPUT_DATA_INDEX = 'search-source-alert-output';
|
||||
const ACTION_TYPE_ID = '.index';
|
||||
const RULE_NAME = 'test-search-source-alert';
|
||||
let sourceDataViewId: string;
|
||||
let outputDataViewId: string;
|
||||
let connectorId: string;
|
||||
|
||||
const createSourceIndex = () =>
|
||||
es.index({
|
||||
index: SOURCE_DATA_INDEX,
|
||||
body: {
|
||||
settings: { number_of_shards: 1 },
|
||||
mappings: {
|
||||
properties: {
|
||||
'@timestamp': { type: 'date' },
|
||||
message: { type: 'text' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const generateNewDocs = async (docsNumber: number) => {
|
||||
const mockMessages = new Array(docsNumber).map((current) => `msg-${current}`);
|
||||
const dateNow = new Date().toISOString();
|
||||
for (const message of mockMessages) {
|
||||
await es.transport.request({
|
||||
path: `/${SOURCE_DATA_INDEX}/_doc`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
'@timestamp': dateNow,
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createOutputDataIndex = () =>
|
||||
es.index({
|
||||
index: OUTPUT_DATA_INDEX,
|
||||
body: {
|
||||
settings: {
|
||||
number_of_shards: 1,
|
||||
},
|
||||
mappings: {
|
||||
properties: {
|
||||
rule_id: { type: 'text' },
|
||||
rule_name: { type: 'text' },
|
||||
alert_id: { type: 'text' },
|
||||
context_message: { type: 'text' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAlerts = (alertIds: string[]) =>
|
||||
asyncForEach(alertIds, async (alertId: string) => {
|
||||
await supertest
|
||||
.delete(`/api/alerting/rule/${alertId}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(204, '');
|
||||
});
|
||||
|
||||
const getAlertsByName = async (name: string) => {
|
||||
const {
|
||||
body: { data: alerts },
|
||||
} = await supertest
|
||||
.get(`/api/alerting/rules/_find?search=${name}&search_fields=name`)
|
||||
.expect(200);
|
||||
|
||||
return alerts;
|
||||
};
|
||||
|
||||
const createDataView = async (dataView: string) => {
|
||||
log.debug(`create data view ${dataView}`);
|
||||
return await supertest
|
||||
.post(`/api/data_views/data_view`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ data_view: { title: dataView, timeFieldName: '@timestamp' } })
|
||||
.expect(200);
|
||||
};
|
||||
|
||||
const createConnector = async (): Promise<string> => {
|
||||
const { body: createdAction } = await supertest
|
||||
.post(`/api/actions/connector`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'search-source-alert-test-connector',
|
||||
connector_type_id: ACTION_TYPE_ID,
|
||||
config: { index: OUTPUT_DATA_INDEX },
|
||||
secrets: {},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
return createdAction.id;
|
||||
};
|
||||
|
||||
const deleteConnector = (id: string) =>
|
||||
supertest.delete(`/api/actions/connector/${id}`).set('kbn-xsrf', 'foo').expect(204, '');
|
||||
|
||||
const deleteDataViews = (dataViews: string[]) =>
|
||||
asyncForEach(
|
||||
dataViews,
|
||||
async (dataView: string) =>
|
||||
await supertest
|
||||
.delete(`/api/data_views/data_view/${dataView}`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.expect(200)
|
||||
);
|
||||
|
||||
const defineSearchSourceAlert = async (alertName: string) => {
|
||||
await testSubjects.click('discoverAlertsButton');
|
||||
await testSubjects.click('discoverCreateAlertButton');
|
||||
|
||||
await testSubjects.setValue('ruleNameInput', alertName);
|
||||
await testSubjects.click('thresholdPopover');
|
||||
await testSubjects.setValue('alertThresholdInput', '3');
|
||||
await testSubjects.click('.index-ActionTypeSelectOption');
|
||||
|
||||
await monacoEditor.setCodeEditorValue(`{
|
||||
"rule_id": "{{ruleId}}",
|
||||
"rule_name": "{{ruleName}}",
|
||||
"alert_id": "{{alertId}}",
|
||||
"context_message": "{{context.message}}"
|
||||
}`);
|
||||
await testSubjects.click('saveRuleButton');
|
||||
};
|
||||
|
||||
const getLastToast = async () => {
|
||||
const toastList = await testSubjects.find('globalToastList');
|
||||
const titles = await toastList.findAllByCssSelector('.euiToastHeader');
|
||||
const lastTitleElement = last(titles)!;
|
||||
const title = await lastTitleElement.getVisibleText();
|
||||
const messages = await toastList.findAllByCssSelector('.euiToastBody');
|
||||
const lastMessageElement = last(messages)!;
|
||||
const message = await lastMessageElement.getVisibleText();
|
||||
return { message, title };
|
||||
};
|
||||
|
||||
const openOutputIndex = async () => {
|
||||
await PageObjects.common.navigateToApp('discover');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.selectIndexPattern(OUTPUT_DATA_INDEX);
|
||||
|
||||
const [{ id: alertId }] = await getAlertsByName(RULE_NAME);
|
||||
await queryBar.setQuery(`alert_id:${alertId}`);
|
||||
await retry.waitFor('document explorer contains alert', async () => {
|
||||
await queryBar.submitQuery();
|
||||
await PageObjects.discover.waitUntilSearchingHasFinished();
|
||||
return (await dataGrid.getDocCount()) > 0;
|
||||
});
|
||||
};
|
||||
|
||||
const getResultsLink = async () => {
|
||||
// getting the link
|
||||
await dataGrid.clickRowToggle();
|
||||
await testSubjects.click('collapseBtn');
|
||||
const contextMessageElement = await testSubjects.find('tableDocViewRow-context_message-value');
|
||||
const contextMessage = await contextMessageElement.getVisibleText();
|
||||
const [, link] = contextMessage.split(`Link\: `);
|
||||
|
||||
return link;
|
||||
};
|
||||
|
||||
const navigateToDiscover = async (link: string) => {
|
||||
// following ling provided by alert to see documents triggered the alert
|
||||
const baseUrl = deployment.getHostPort();
|
||||
await browser.navigateTo(baseUrl + link);
|
||||
await PageObjects.discover.waitUntilSearchingHasFinished();
|
||||
|
||||
await retry.waitFor('navigate to discover', async () => {
|
||||
const currentUrl = await browser.getCurrentUrl();
|
||||
return currentUrl.includes(sourceDataViewId);
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToResults = async () => {
|
||||
const link = await getResultsLink();
|
||||
await navigateToDiscover(link);
|
||||
};
|
||||
|
||||
const openAlertRule = async () => {
|
||||
await PageObjects.common.navigateToApp('management');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
await testSubjects.click('triggersActions');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
const rulesList = await testSubjects.find('rulesList');
|
||||
const alertRule = await rulesList.findByCssSelector('[title="test-search-source-alert"]');
|
||||
await alertRule.click();
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
};
|
||||
|
||||
describe('Search source Alert', () => {
|
||||
before(async () => {
|
||||
await security.testUser.setRoles(['discover_alert']);
|
||||
|
||||
log.debug('create source index');
|
||||
await createSourceIndex();
|
||||
|
||||
log.debug('generate documents');
|
||||
await generateNewDocs(5);
|
||||
|
||||
log.debug('create output index');
|
||||
await createOutputDataIndex();
|
||||
|
||||
log.debug('create data views');
|
||||
const sourceDataViewResponse = await createDataView(SOURCE_DATA_INDEX);
|
||||
const outputDataViewResponse = await createDataView(OUTPUT_DATA_INDEX);
|
||||
|
||||
log.debug('create connector');
|
||||
connectorId = await createConnector();
|
||||
|
||||
sourceDataViewId = sourceDataViewResponse.body.data_view.id;
|
||||
outputDataViewId = outputDataViewResponse.body.data_view.id;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// delete only remaining output index
|
||||
await es.transport.request({
|
||||
path: `/${OUTPUT_DATA_INDEX}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
await deleteDataViews([sourceDataViewId, outputDataViewId]);
|
||||
await deleteConnector(connectorId);
|
||||
const alertsToDelete = await getAlertsByName(RULE_NAME);
|
||||
await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id));
|
||||
await security.testUser.restoreDefaults();
|
||||
});
|
||||
|
||||
it('should navigate to discover via view in app link', async () => {
|
||||
await PageObjects.common.navigateToApp('discover');
|
||||
await PageObjects.discover.waitUntilSearchingHasFinished();
|
||||
await PageObjects.discover.selectIndexPattern(SOURCE_DATA_INDEX);
|
||||
await PageObjects.timePicker.setCommonlyUsedTime('Last_15 minutes');
|
||||
|
||||
// create an alert
|
||||
await defineSearchSourceAlert(RULE_NAME);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
await openAlertRule();
|
||||
|
||||
await testSubjects.click('ruleDetails-viewInApp');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
await retry.waitFor('navigate to discover', async () => {
|
||||
const currentUrl = await browser.getCurrentUrl();
|
||||
return currentUrl.includes(sourceDataViewId);
|
||||
});
|
||||
|
||||
expect(await dataGrid.getDocCount()).to.be(5);
|
||||
});
|
||||
|
||||
it('should open documents triggered the alert', async () => {
|
||||
await openOutputIndex();
|
||||
await navigateToResults();
|
||||
|
||||
const { message, title } = await getLastToast();
|
||||
expect(await dataGrid.getDocCount()).to.be(5);
|
||||
expect(title).to.be.equal('Displayed documents may vary');
|
||||
expect(message).to.be.equal(
|
||||
'The displayed documents might differ from the documents that triggered the alert. Some documents might have been added or deleted.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should display warning about updated alert rule', async () => {
|
||||
await openAlertRule();
|
||||
|
||||
// change rule configuration
|
||||
await testSubjects.click('openEditRuleFlyoutButton');
|
||||
await testSubjects.click('thresholdPopover');
|
||||
await testSubjects.setValue('alertThresholdInput', '1');
|
||||
await testSubjects.click('saveEditedRuleButton');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
await openOutputIndex();
|
||||
await navigateToResults();
|
||||
|
||||
const { message, title } = await getLastToast();
|
||||
expect(await dataGrid.getDocCount()).to.be(5);
|
||||
expect(title).to.be.equal('Alert rule has changed');
|
||||
expect(message).to.be.equal(
|
||||
'The displayed documents might not match the documents that triggered the alert because the rule configuration changed.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should display not found index error', async () => {
|
||||
await openOutputIndex();
|
||||
const link = await getResultsLink();
|
||||
await navigateToDiscover(link);
|
||||
|
||||
await es.transport.request({
|
||||
path: `/${SOURCE_DATA_INDEX}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
await browser.refresh();
|
||||
|
||||
await navigateToDiscover(link);
|
||||
|
||||
const { title } = await getLastToast();
|
||||
expect(title).to.be.equal(
|
||||
'No matching indices found: No indices match "search-source-alert"'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -48,6 +48,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
pageObjects,
|
||||
// list paths to the files that contain your plugins tests
|
||||
testFiles: [
|
||||
resolve(__dirname, './apps/discover'),
|
||||
resolve(__dirname, './apps/triggers_actions_ui'),
|
||||
resolve(__dirname, './apps/uptime'),
|
||||
resolve(__dirname, './apps/ml'),
|
||||
|
@ -111,6 +112,30 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
},
|
||||
],
|
||||
},
|
||||
discover_alert: {
|
||||
kibana: [
|
||||
{
|
||||
feature: {
|
||||
actions: ['all'],
|
||||
stackAlerts: ['all'],
|
||||
discover: ['all'],
|
||||
advancedSettings: ['all'],
|
||||
},
|
||||
spaces: ['*'],
|
||||
},
|
||||
],
|
||||
elasticsearch: {
|
||||
cluster: [],
|
||||
indices: [
|
||||
{
|
||||
names: ['search-source-alert', 'search-source-alert-output'],
|
||||
privileges: ['read', 'view_index_metadata', 'manage', 'create_index', 'index'],
|
||||
field_security: { grant: ['*'], except: [] },
|
||||
},
|
||||
],
|
||||
run_as: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultRoles: ['superuser'],
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue