From 3e22323e0070bb7729f3f0d4a789b8a97bc370c2 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 15 Nov 2022 18:47:27 +0100 Subject: [PATCH 01/13] [ML] Change Point Detection (#144093) ## Summary Adds a Change point detection page in the AIOps labs. _Note:_ This page will be hidden under the hardcoded feature flag for 8.6. image ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- x-pack/plugins/aiops/kibana.json | 1 + .../public/application/utils/search_utils.ts | 2 +- .../change_point_detection_context.tsx | 275 ++++++++++++++++++ .../change_point_detection_page.tsx | 205 +++++++++++++ .../change_point_detetion_root.tsx | 42 +++ .../chart_component.tsx | 215 ++++++++++++++ .../change_point_detection/constants.ts | 13 + .../function_picker.tsx | 38 +++ .../change_point_detection/index.ts | 14 + .../metric_field_selector.tsx | 39 +++ .../change_point_detection/search_bar.tsx | 100 +++++++ .../split_field_selector.tsx | 37 +++ .../use_change_point_agg_request.ts | 263 +++++++++++++++++ .../use_split_field_cardinality.ts | 69 +++++ .../date_picker_wrapper.tsx | 33 +-- .../full_time_range_selector.tsx | 11 +- .../public/components/page_header/index.ts | 8 + .../components/page_header/page_header.tsx | 80 +++++ .../public/hooks/use_aiops_app_context.ts | 2 + .../public/hooks/use_cancellable_search.ts | 67 +++++ x-pack/plugins/aiops/public/hooks/use_data.ts | 12 +- .../aiops/public/hooks/use_data_source.ts | 25 ++ .../aiops/public/hooks/use_time_buckets.ts | 24 ++ .../aiops/public/hooks/use_time_filter.ts | 33 ++- .../aiops/public/hooks/use_url_state.tsx | 4 +- x-pack/plugins/aiops/public/index.ts | 6 +- .../aiops/public/shared_lazy_components.tsx | 27 +- x-pack/plugins/aiops/tsconfig.json | 1 + x-pack/plugins/ml/common/constants/locator.ts | 2 + x-pack/plugins/ml/common/types/locator.ts | 4 +- x-pack/plugins/ml/kibana.json | 2 +- .../aiops/change_point_detection.tsx | 68 +++++ .../aiops/explain_log_rate_spikes.tsx | 1 + .../ml/public/application/aiops/index.ts | 1 + .../application/aiops/log_categorization.tsx | 1 + x-pack/plugins/ml/public/application/app.tsx | 1 + .../components/ml_page/side_nav.tsx | 31 +- .../contexts/kibana/kibana_context.ts | 2 + .../public/application/routing/breadcrumbs.ts | 16 + .../routes/aiops/change_point_detection.tsx | 55 ++++ .../application/routing/routes/aiops/index.ts | 1 + .../routes/new_job/index_or_search.tsx | 35 ++- .../plugins/ml/public/locator/ml_locator.ts | 2 + .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 46 files changed, 1805 insertions(+), 66 deletions(-) create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/constants.ts create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/function_picker.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/index.ts create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/search_bar.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts create mode 100644 x-pack/plugins/aiops/public/components/page_header/index.ts create mode 100644 x-pack/plugins/aiops/public/components/page_header/page_header.tsx create mode 100644 x-pack/plugins/aiops/public/hooks/use_cancellable_search.ts create mode 100644 x-pack/plugins/aiops/public/hooks/use_data_source.ts create mode 100644 x-pack/plugins/aiops/public/hooks/use_time_buckets.ts create mode 100644 x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx create mode 100644 x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx diff --git a/x-pack/plugins/aiops/kibana.json b/x-pack/plugins/aiops/kibana.json index ce8057bc03f04..dba431234ec0a 100755 --- a/x-pack/plugins/aiops/kibana.json +++ b/x-pack/plugins/aiops/kibana.json @@ -12,6 +12,7 @@ "requiredPlugins": [ "charts", "data", + "lens", "licensing" ], "optionalPlugins": [], diff --git a/x-pack/plugins/aiops/public/application/utils/search_utils.ts b/x-pack/plugins/aiops/public/application/utils/search_utils.ts index 93481cbe5658d..d3643daace942 100644 --- a/x-pack/plugins/aiops/public/application/utils/search_utils.ts +++ b/x-pack/plugins/aiops/public/application/utils/search_utils.ts @@ -204,7 +204,7 @@ export function getEsQueryFromSavedSearch({ }; } - // If saved search available, merge saved search with latest user query or filters + // If saved search available, merge saved search with the latest user query or filters // which might differ from extracted saved search data if (savedSearchData) { const globalFilters = filterManager?.getGlobalFilters(); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx new file mode 100644 index 0000000000000..7fd586953fe96 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx @@ -0,0 +1,275 @@ +/* + * 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, { + createContext, + FC, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { type DataViewField } from '@kbn/data-views-plugin/public'; +import { startWith } from 'rxjs'; +import useMount from 'react-use/lib/useMount'; +import type { Query, Filter } from '@kbn/es-query'; +import { + createMergedEsQuery, + getEsQueryFromSavedSearch, +} from '../../application/utils/search_utils'; +import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; +import { useTimefilter, useTimeRangeUpdates } from '../../hooks/use_time_filter'; +import { useChangePointResults } from './use_change_point_agg_request'; +import { type TimeBuckets, TimeBucketsInterval } from '../../../common/time_buckets'; +import { useDataSource } from '../../hooks/use_data_source'; +import { usePageUrlState } from '../../hooks/use_url_state'; +import { useTimeBuckets } from '../../hooks/use_time_buckets'; + +export interface ChangePointDetectionRequestParams { + fn: string; + splitField: string; + metricField: string; + interval: string; + query: Query; + filters: Filter[]; +} + +export const ChangePointDetectionContext = createContext<{ + timeBuckets: TimeBuckets; + bucketInterval: TimeBucketsInterval; + requestParams: ChangePointDetectionRequestParams; + metricFieldOptions: DataViewField[]; + splitFieldsOptions: DataViewField[]; + updateRequestParams: (update: Partial) => void; + isLoading: boolean; + annotations: ChangePointAnnotation[]; + resultFilters: Filter[]; + updateFilters: (update: Filter[]) => void; + resultQuery: Query; + progress: number; + pagination: { + activePage: number; + pageCount: number; + updatePagination: (newPage: number) => void; + }; +}>({ + isLoading: false, + splitFieldsOptions: [], + metricFieldOptions: [], + requestParams: {} as ChangePointDetectionRequestParams, + timeBuckets: {} as TimeBuckets, + bucketInterval: {} as TimeBucketsInterval, + updateRequestParams: () => {}, + annotations: [], + resultFilters: [], + updateFilters: () => {}, + resultQuery: { query: '', language: 'kuery' }, + progress: 0, + pagination: { + activePage: 0, + pageCount: 1, + updatePagination: () => {}, + }, +}); + +export type ChangePointType = + | 'dip' + | 'spike' + | 'distribution_change' + | 'step_change' + | 'trend_change' + | 'stationary' + | 'non_stationary' + | 'indeterminable'; + +export interface ChangePointAnnotation { + label: string; + reason: string; + timestamp: string; + group_field: string; + type: ChangePointType; + p_value: number; +} + +const DEFAULT_AGG_FUNCTION = 'min'; + +export const ChangePointDetectionContextProvider: FC = ({ children }) => { + const { dataView, savedSearch } = useDataSource(); + const { + uiSettings, + data: { + query: { filterManager }, + }, + } = useAiopsAppContext(); + + const savedSearchQuery = useMemo(() => { + return getEsQueryFromSavedSearch({ + dataView, + uiSettings, + savedSearch, + filterManager, + }); + }, [dataView, savedSearch, uiSettings, filterManager]); + + const timefilter = useTimefilter(); + const timeBuckets = useTimeBuckets(); + const [resultFilters, setResultFilter] = useState([]); + + const [bucketInterval, setBucketInterval] = useState(); + + const timeRange = useTimeRangeUpdates(); + + useMount(function updateIntervalOnTimeBoundsChange() { + const timeUpdateSubscription = timefilter + .getTimeUpdate$() + .pipe(startWith(timefilter.getTime())) + .subscribe(() => { + const activeBounds = timefilter.getActiveBounds(); + if (!activeBounds) { + throw new Error('Time bound not available'); + } + timeBuckets.setInterval('auto'); + timeBuckets.setBounds(activeBounds); + setBucketInterval(timeBuckets.getInterval()); + }); + return () => { + timeUpdateSubscription.unsubscribe(); + }; + }); + + const metricFieldOptions = useMemo(() => { + return dataView.fields.filter(({ aggregatable, type }) => aggregatable && type === 'number'); + }, [dataView]); + + const splitFieldsOptions = useMemo(() => { + return dataView.fields.filter( + ({ aggregatable, esTypes, displayName }) => + aggregatable && + esTypes && + esTypes.includes('keyword') && + !['_id', '_index'].includes(displayName) + ); + }, [dataView]); + + const [requestParamsFromUrl, updateRequestParams] = + usePageUrlState('changePoint'); + + const resultQuery = useMemo(() => { + return ( + requestParamsFromUrl.query ?? { + query: savedSearchQuery?.searchString ?? '', + language: savedSearchQuery?.queryLanguage ?? 'kuery', + } + ); + }, [savedSearchQuery, requestParamsFromUrl.query]); + + const requestParams = useMemo(() => { + const params = { ...requestParamsFromUrl }; + if (!params.fn) { + params.fn = DEFAULT_AGG_FUNCTION; + } + if (!params.metricField && metricFieldOptions.length > 0) { + params.metricField = metricFieldOptions[0].name; + } + if (!params.splitField && splitFieldsOptions.length > 0) { + params.splitField = splitFieldsOptions[0].name; + } + params.interval = bucketInterval?.expression!; + return params; + }, [requestParamsFromUrl, metricFieldOptions, splitFieldsOptions, bucketInterval]); + + const updateFilters = useCallback( + (update: Filter[]) => { + filterManager.setFilters(update); + }, + [filterManager] + ); + + useMount(() => { + setResultFilter(filterManager.getFilters()); + const sub = filterManager.getUpdates$().subscribe(() => { + setResultFilter(filterManager.getFilters()); + }); + return () => { + sub.unsubscribe(); + }; + }); + + useEffect( + function syncFilters() { + const globalFilters = filterManager?.getGlobalFilters(); + if (requestParamsFromUrl.filters) { + filterManager.setFilters(requestParamsFromUrl.filters); + } + if (globalFilters) { + filterManager?.addFilters(globalFilters); + } + }, + [requestParamsFromUrl.filters, filterManager] + ); + + const combinedQuery = useMemo(() => { + const mergedQuery = createMergedEsQuery(resultQuery, resultFilters, dataView, uiSettings); + if (!Array.isArray(mergedQuery.bool?.filter)) { + if (!mergedQuery.bool) { + mergedQuery.bool = {}; + } + mergedQuery.bool.filter = []; + } + + mergedQuery.bool!.filter.push({ + range: { + [dataView.timeFieldName!]: { + from: timeRange.from, + to: timeRange.to, + }, + }, + }); + + return mergedQuery; + }, [resultFilters, resultQuery, uiSettings, dataView, timeRange]); + + const { + results: annotations, + isLoading: annotationsLoading, + progress, + pagination, + } = useChangePointResults(requestParams, combinedQuery); + + if (!bucketInterval) return null; + + const value = { + isLoading: annotationsLoading, + progress, + timeBuckets, + requestParams, + updateRequestParams, + metricFieldOptions, + splitFieldsOptions, + annotations, + bucketInterval, + resultFilters, + updateFilters, + resultQuery, + pagination, + }; + + return ( + + {children} + + ); +}; + +export function useChangePointDetectionContext() { + return useContext(ChangePointDetectionContext); +} + +export function useRequestParams() { + return useChangePointDetectionContext().requestParams; +} diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx new file mode 100644 index 0000000000000..8e5c06b38f85c --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx @@ -0,0 +1,205 @@ +/* + * 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, { FC, useCallback } from 'react'; +import { + EuiBadge, + EuiDescriptionList, + EuiEmptyPrompt, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPagination, + EuiPanel, + EuiProgress, + EuiSpacer, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { Query } from '@kbn/es-query'; +import { SearchBarWrapper } from './search_bar'; +import { useChangePointDetectionContext } from './change_point_detection_context'; +import { MetricFieldSelector } from './metric_field_selector'; +import { SplitFieldSelector } from './split_field_selector'; +import { FunctionPicker } from './function_picker'; +import { ChartComponent } from './chart_component'; + +export const ChangePointDetectionPage: FC = () => { + const { + requestParams, + updateRequestParams, + annotations, + resultFilters, + updateFilters, + resultQuery, + progress, + pagination, + } = useChangePointDetectionContext(); + + const setFn = useCallback( + (fn: string) => { + updateRequestParams({ fn }); + }, + [updateRequestParams] + ); + + const setSplitField = useCallback( + (splitField: string) => { + updateRequestParams({ splitField }); + }, + [updateRequestParams] + ); + + const setMetricField = useCallback( + (metricField: string) => { + updateRequestParams({ metricField }); + }, + [updateRequestParams] + ); + + const setQuery = useCallback( + (query: Query) => { + updateRequestParams({ query }); + }, + [updateRequestParams] + ); + + const selectControlCss = { width: '200px' }; + + return ( +
+ + + + + + + + + + + + + + + + + + } + value={progress} + max={100} + valueText + size="m" + /> + + + + + + + {annotations.length === 0 && progress === 100 ? ( + <> + + + + } + body={ +

+ +

+ } + /> + + ) : null} + + = 2 ? 2 : 1} responsive gutterSize={'m'}> + {annotations.map((v) => { + return ( + + + + + + + +

{v.group_field}

+
+
+ {v.reason ? ( + + + + + + ) : null} +
+
+ + {v.type} + +
+ + {v.p_value !== undefined ? ( + + ) : null} + +
+
+ ); + })} +
+ + + + {pagination.pageCount > 1 ? ( + + + + + + ) : null} +
+ ); +}; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx new file mode 100644 index 0000000000000..0b4a14928a19c --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx @@ -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 { DataView } from '@kbn/data-views-plugin/common'; +import { SavedSearch } from '@kbn/saved-search-plugin/public'; +import React, { FC } from 'react'; +import { PageHeader } from '../page_header'; +import { ChangePointDetectionContextProvider } from './change_point_detection_context'; +import { DataSourceContext } from '../../hooks/use_data_source'; +import { UrlStateProvider } from '../../hooks/use_url_state'; +import { SavedSearchSavedObject } from '../../application/utils/search_utils'; +import { AiopsAppContext, AiopsAppDependencies } from '../../hooks/use_aiops_app_context'; +import { ChangePointDetectionPage } from './change_point_detection_page'; + +export interface ChangePointDetectionAppStateProps { + dataView: DataView; + savedSearch: SavedSearch | SavedSearchSavedObject | null; + appDependencies: AiopsAppDependencies; +} + +export const ChangePointDetectionAppState: FC = ({ + dataView, + savedSearch, + appDependencies, +}) => { + return ( + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx new file mode 100644 index 0000000000000..becfbd813fd4c --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx @@ -0,0 +1,215 @@ +/* + * 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, { FC, useMemo } from 'react'; +import { type TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { FilterStateStore } from '@kbn/es-query'; +import { useChangePointDetectionContext } from './change_point_detection_context'; +import { useDataSource } from '../../hooks/use_data_source'; +import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; +import { useTimeRangeUpdates } from '../../hooks/use_time_filter'; +import { fnOperationTypeMapping } from './constants'; + +export interface ChartComponentProps { + annotation: { + group_field: string; + label: string; + timestamp: string; + reason: string; + }; +} + +export const ChartComponent: FC = React.memo(({ annotation }) => { + const { + lens: { EmbeddableComponent }, + } = useAiopsAppContext(); + + const timeRange = useTimeRangeUpdates(); + const { dataView } = useDataSource(); + const { requestParams, bucketInterval } = useChangePointDetectionContext(); + + const filters = useMemo( + () => [ + { + meta: { + index: dataView.id!, + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: requestParams.splitField, + params: { + query: annotation.group_field, + }, + }, + query: { + match_phrase: { + [requestParams.splitField]: annotation.group_field, + }, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + ], + [dataView.id, requestParams.splitField, annotation.group_field] + ); + + // @ts-ignore incorrect types for attributes + const attributes = useMemo(() => { + return { + title: annotation.group_field, + description: '', + visualizationType: 'lnsXY', + type: 'lens', + references: [ + { + type: 'index-pattern', + id: dataView.id!, + name: 'indexpattern-datasource-layer-2d61a885-abb0-4d4e-a5f9-c488caec3c22', + }, + { + type: 'index-pattern', + id: dataView.id!, + name: 'xy-visualization-layer-8d26ab67-b841-4877-9d02-55bf270f9caf', + }, + ], + state: { + visualization: { + legend: { + isVisible: false, + position: 'right', + }, + valueLabels: 'hide', + fittingFunction: 'None', + axisTitlesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + preferredSeriesType: 'line', + layers: [ + { + layerId: '2d61a885-abb0-4d4e-a5f9-c488caec3c22', + accessors: ['e9f26d17-fb36-4982-8539-03f1849cbed0'], + position: 'top', + seriesType: 'line', + showGridlines: false, + layerType: 'data', + xAccessor: '877e6638-bfaa-43ec-afb9-2241dc8e1c86', + }, + ...(annotation.timestamp + ? [ + { + layerId: '8d26ab67-b841-4877-9d02-55bf270f9caf', + layerType: 'annotations', + annotations: [ + { + type: 'manual', + label: annotation.label, + icon: 'triangle', + textVisibility: true, + key: { + type: 'point_in_time', + timestamp: annotation.timestamp, + }, + id: 'a8fb297c-8d96-4011-93c0-45af110d5302', + isHidden: false, + color: '#F04E98', + lineStyle: 'solid', + lineWidth: 2, + outside: false, + }, + ], + ignoreGlobalFilters: true, + }, + ] + : []), + ], + }, + query: { + query: '', + language: 'kuery', + }, + filters, + datasourceStates: { + formBased: { + layers: { + '2d61a885-abb0-4d4e-a5f9-c488caec3c22': { + columns: { + '877e6638-bfaa-43ec-afb9-2241dc8e1c86': { + label: dataView.timeFieldName, + dataType: 'date', + operationType: 'date_histogram', + sourceField: dataView.timeFieldName, + isBucketed: true, + scale: 'interval', + params: { + interval: bucketInterval.expression, + includeEmptyRows: true, + dropPartials: false, + }, + }, + 'e9f26d17-fb36-4982-8539-03f1849cbed0': { + label: `${requestParams.fn}(${requestParams.metricField})`, + dataType: 'number', + operationType: fnOperationTypeMapping[requestParams.fn], + sourceField: requestParams.metricField, + isBucketed: false, + scale: 'ratio', + params: { + emptyAsNull: true, + }, + }, + }, + columnOrder: [ + '877e6638-bfaa-43ec-afb9-2241dc8e1c86', + 'e9f26d17-fb36-4982-8539-03f1849cbed0', + ], + incompleteColumns: {}, + }, + }, + }, + textBased: { + layers: {}, + }, + }, + internalReferences: [], + adHocDataViews: {}, + }, + }; + }, [dataView.id, dataView.timeFieldName, annotation, requestParams, filters, bucketInterval]); + + return ( + + ); +}); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/constants.ts b/x-pack/plugins/aiops/public/components/change_point_detection/constants.ts new file mode 100644 index 0000000000000..d6bbd27858fef --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/constants.ts @@ -0,0 +1,13 @@ +/* + * 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 fnOperationTypeMapping: Record = { + min: 'min', + max: 'max', + sum: 'sum', + avg: 'average', +} as const; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/function_picker.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/function_picker.tsx new file mode 100644 index 0000000000000..52a304d85fb85 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/function_picker.tsx @@ -0,0 +1,38 @@ +/* + * 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 { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { FC } from 'react'; +import { fnOperationTypeMapping } from './constants'; + +interface FunctionPickerProps { + value: string; + onChange: (value: string) => void; +} + +export const FunctionPicker: FC = React.memo(({ value, onChange }) => { + const options = Object.keys(fnOperationTypeMapping).map((v) => { + return { + value: v, + text: v, + }; + }); + + return ( + + onChange(e.target.value)} + prepend={i18n.translate('xpack.aiops.changePointDetection.selectFunctionLabel', { + defaultMessage: 'Function', + })} + /> + + ); +}); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/index.ts b/x-pack/plugins/aiops/public/components/change_point_detection/index.ts new file mode 100644 index 0000000000000..5083f19cfe39f --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/index.ts @@ -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. + */ + +export { type ChangePointDetectionAppStateProps } from './change_point_detetion_root'; + +import { ChangePointDetectionAppState } from './change_point_detetion_root'; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default ChangePointDetectionAppState; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx new file mode 100644 index 0000000000000..bbdc3e5742b08 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx @@ -0,0 +1,39 @@ +/* + * 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, { FC, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiSelect, EuiSelectOption } from '@elastic/eui'; +import { useChangePointDetectionContext } from './change_point_detection_context'; + +interface MetricFieldSelectorProps { + value: string; + onChange: (value: string) => void; +} + +export const MetricFieldSelector: FC = React.memo( + ({ value, onChange }) => { + const { metricFieldOptions } = useChangePointDetectionContext(); + + const options = useMemo(() => { + return metricFieldOptions.map((v) => ({ value: v.name, text: v.displayName })); + }, [metricFieldOptions]); + + return ( + + onChange(e.target.value)} + /> + + ); + } +); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/search_bar.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/search_bar.tsx new file mode 100644 index 0000000000000..020c5da876b2a --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/search_bar.tsx @@ -0,0 +1,100 @@ +/* + * 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, { FC, useCallback, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { type Filter, fromKueryExpression, type Query } from '@kbn/es-query'; +import { type SearchBarOwnProps } from '@kbn/unified-search-plugin/public/search_bar'; +import { EuiSpacer, EuiTextColor } from '@elastic/eui'; +import { SEARCH_QUERY_LANGUAGE } from '../../application/utils/search_utils'; +import { useDataSource } from '../../hooks/use_data_source'; +import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; + +export interface SearchBarProps { + query: Query; + filters: Filter[]; + onQueryChange: (update: Query) => void; + onFiltersChange: (update: Filter[]) => void; +} + +/** + * Reusable search bar component for the AIOps app. + * + * @param query + * @param filters + * @param onQueryChange + * @param onFiltersChange + * @constructor + */ +export const SearchBarWrapper: FC = ({ + query, + filters, + onQueryChange, + onFiltersChange, +}) => { + const { dataView } = useDataSource(); + const { + unifiedSearch: { + ui: { SearchBar }, + }, + } = useAiopsAppContext(); + + const [error, setError] = useState(); + + const onQuerySubmit: SearchBarOwnProps['onQuerySubmit'] = useCallback( + (payload, isUpdate) => { + if (payload.query.language === SEARCH_QUERY_LANGUAGE.KUERY) { + try { + // Validates the query + fromKueryExpression(payload.query.query); + setError(undefined); + onQueryChange(payload.query); + } catch (e) { + setError(e.message); + } + } + }, + [onQueryChange] + ); + + const onFiltersUpdated = useCallback( + (updatedFilters: Filter[]) => { + onFiltersChange(updatedFilters); + }, + [onFiltersChange] + ); + + const resultQuery = query ?? { query: '', language: 'kuery' }; + + return ( + <> + + {error ? ( + <> + + {error} + + ) : null} + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx new file mode 100644 index 0000000000000..1a91e69af65ba --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiSelect, type EuiSelectOption } from '@elastic/eui'; +import { useChangePointDetectionContext } from './change_point_detection_context'; + +interface SplitFieldSelectorProps { + value: string; + onChange: (value: string) => void; +} + +export const SplitFieldSelector: FC = React.memo(({ value, onChange }) => { + const { splitFieldsOptions } = useChangePointDetectionContext(); + + const options = useMemo(() => { + return splitFieldsOptions.map((v) => ({ value: v.name, text: v.displayName })); + }, [splitFieldsOptions]); + + return ( + + onChange(e.target.value)} + /> + + ); +}); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts b/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts new file mode 100644 index 0000000000000..b00c5dab790b7 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts @@ -0,0 +1,263 @@ +/* + * 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 { useEffect, useCallback, useState, useMemo } from 'react'; +import { type QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { i18n } from '@kbn/i18n'; +import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; +import { + ChangePointAnnotation, + ChangePointDetectionRequestParams, + ChangePointType, +} from './change_point_detection_context'; +import { useDataSource } from '../../hooks/use_data_source'; +import { useCancellableSearch } from '../../hooks/use_cancellable_search'; +import { useSplitFieldCardinality } from './use_split_field_cardinality'; + +interface RequestOptions { + index: string; + fn: string; + metricField: string; + splitField: string; + timeField: string; + timeInterval: string; + afterKey?: string; +} + +export const COMPOSITE_AGG_SIZE = 500; + +function getChangePointDetectionRequestBody( + { index, fn, metricField, splitField, timeInterval, timeField, afterKey }: RequestOptions, + query: QueryDslQueryContainer +) { + return { + params: { + index, + size: 0, + body: { + query, + aggregations: { + groupings: { + composite: { + size: COMPOSITE_AGG_SIZE, + ...(afterKey !== undefined ? { after: { splitFieldTerm: afterKey } } : {}), + sources: [ + { + splitFieldTerm: { + terms: { + field: splitField, + }, + }, + }, + ], + }, + aggregations: { + over_time: { + date_histogram: { + field: timeField, + fixed_interval: timeInterval, + }, + aggs: { + function_value: { + [fn]: { + field: metricField, + }, + }, + }, + }, + change_point_request: { + change_point: { + buckets_path: 'over_time>function_value', + }, + }, + select: { + bucket_selector: { + buckets_path: { p_value: 'change_point_request.p_value' }, + script: 'params.p_value < 1', + }, + }, + sort: { + bucket_sort: { + sort: [{ 'change_point_request.p_value': { order: 'asc' } }], + }, + }, + }, + }, + }, + }, + }, + }; +} + +const CHARTS_PER_PAGE = 6; + +export function useChangePointResults( + requestParams: ChangePointDetectionRequestParams, + query: QueryDslQueryContainer +) { + const { + notifications: { toasts }, + } = useAiopsAppContext(); + + const { dataView } = useDataSource(); + + const [results, setResults] = useState([]); + const [activePage, setActivePage] = useState(0); + const [progress, setProgress] = useState(0); + + const splitFieldCardinality = useSplitFieldCardinality(requestParams.splitField, query); + + const { runRequest, cancelRequest, isLoading } = useCancellableSearch(); + + const reset = useCallback(() => { + cancelRequest(); + setProgress(0); + setActivePage(0); + setResults([]); + }, [cancelRequest]); + + const fetchResults = useCallback( + async (afterKey?: string, prevBucketsCount?: number) => { + try { + if (!splitFieldCardinality) { + setProgress(100); + return; + } + + const requestPayload = getChangePointDetectionRequestBody( + { + index: dataView.getIndexPattern(), + fn: requestParams.fn, + timeInterval: requestParams.interval, + metricField: requestParams.metricField, + timeField: dataView.timeFieldName!, + splitField: requestParams.splitField, + afterKey, + }, + query + ); + const result = await runRequest< + typeof requestPayload, + { rawResponse: ChangePointAggResponse } + >(requestPayload); + + if (result === null) { + setProgress(100); + return; + } + + const buckets = result.rawResponse.aggregations.groupings.buckets; + + setProgress( + Math.min( + Math.round(((buckets.length + (prevBucketsCount ?? 0)) / splitFieldCardinality) * 100), + 100 + ) + ); + + const groups = buckets.map((v) => { + const changePointType = Object.keys(v.change_point_request.type)[0] as ChangePointType; + const timeAsString = v.change_point_request.bucket?.key; + const rawPValue = v.change_point_request.type[changePointType].p_value; + + return { + group_field: v.key.splitFieldTerm, + type: changePointType, + p_value: rawPValue, + timestamp: timeAsString, + label: changePointType, + reason: v.change_point_request.type[changePointType].reason, + } as ChangePointAnnotation; + }); + + setResults((prev) => { + return ( + (prev ?? []) + .concat(groups) + // Lower p_value indicates a bigger change point, hence the acs sorting + .sort((a, b) => a.p_value - b.p_value) + ); + }); + + if (result.rawResponse.aggregations.groupings.after_key?.splitFieldTerm) { + await fetchResults( + result.rawResponse.aggregations.groupings.after_key.splitFieldTerm, + buckets.length + (prevBucketsCount ?? 0) + ); + } else { + setProgress(100); + } + } catch (e) { + toasts.addError(e, { + title: i18n.translate('xpack.aiops.changePointDetection.fetchErrorTitle', { + defaultMessage: 'Failed to fetch change points', + }), + }); + } + }, + [runRequest, requestParams, query, dataView, splitFieldCardinality, toasts] + ); + + useEffect( + function fetchResultsOnInputChange() { + reset(); + fetchResults(); + + return () => { + cancelRequest(); + }; + }, + [requestParams, query, splitFieldCardinality, fetchResults, reset, cancelRequest] + ); + + const pagination = useMemo(() => { + return { + activePage, + pageCount: Math.round((results.length ?? 0) / CHARTS_PER_PAGE), + updatePagination: setActivePage, + }; + }, [activePage, results.length]); + + const resultPerPage = useMemo(() => { + const start = activePage * CHARTS_PER_PAGE; + return results.slice(start, start + CHARTS_PER_PAGE); + }, [results, activePage]); + + return { results: resultPerPage, isLoading, reset, progress, pagination }; +} + +interface ChangePointAggResponse { + took: number; + timed_out: boolean; + _shards: { total: number; failed: number; successful: number; skipped: number }; + hits: { hits: any[]; total: number; max_score: null }; + aggregations: { + groupings: { + after_key?: { + splitFieldTerm: string; + }; + buckets: Array<{ + key: { splitFieldTerm: string }; + doc_count: number; + over_time: { + buckets: Array<{ + key_as_string: string; + doc_count: number; + function_value: { value: number }; + key: number; + }>; + }; + change_point_request: { + bucket?: { doc_count: number; function_value: { value: number }; key: string }; + type: { + [key in ChangePointType]: { p_value: number; change_point: number; reason?: string }; + }; + }; + }>; + }; + }; +} diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts b/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts new file mode 100644 index 0000000000000..19e8212f1b8a8 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts @@ -0,0 +1,69 @@ +/* + * 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 { useEffect, useMemo, useState } from 'react'; +import type { + QueryDslQueryContainer, + AggregationsCardinalityAggregate, + SearchResponseBody, +} from '@elastic/elasticsearch/lib/api/types'; +import { useCancellableSearch } from '../../hooks/use_cancellable_search'; +import { useDataSource } from '../../hooks/use_data_source'; + +/** + * Gets the cardinality of the selected split field + * @param splitField + * @param query + */ +export function useSplitFieldCardinality(splitField: string, query: QueryDslQueryContainer) { + const [cardinality, setCardinality] = useState(); + const { dataView } = useDataSource(); + + const requestPayload = useMemo(() => { + return { + params: { + index: dataView.getIndexPattern(), + size: 0, + body: { + query, + aggregations: { + fieldCount: { + cardinality: { + field: splitField, + }, + }, + }, + }, + }, + }; + }, [splitField, dataView, query]); + + const { runRequest: getSplitFieldCardinality, cancelRequest } = useCancellableSearch(); + + useEffect( + function performCardinalityCheck() { + cancelRequest(); + + getSplitFieldCardinality< + typeof requestPayload, + { + rawResponse: SearchResponseBody< + unknown, + { fieldCount: AggregationsCardinalityAggregate } + >; + } + >(requestPayload).then((response) => { + if (response?.rawResponse.aggregations) { + setCardinality(response.rawResponse.aggregations.fieldCount.value); + } + }); + }, + [getSplitFieldCardinality, requestPayload, cancelRequest] + ); + + return cardinality; +} diff --git a/x-pack/plugins/aiops/public/components/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/aiops/public/components/date_picker_wrapper/date_picker_wrapper.tsx index 6b3e57200b90b..566d3b6ae7b5b 100644 --- a/x-pack/plugins/aiops/public/components/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/aiops/public/components/date_picker_wrapper/date_picker_wrapper.tsx @@ -21,13 +21,12 @@ import { OnTimeChangeProps, } from '@elastic/eui'; import type { TimeRange } from '@kbn/es-query'; -import { TimefilterContract, TimeHistoryContract, UI_SETTINGS } from '@kbn/data-plugin/public'; +import { TimeHistoryContract, UI_SETTINGS } from '@kbn/data-plugin/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import useObservable from 'react-use/lib/useObservable'; -import { map } from 'rxjs/operators'; import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; +import { useRefreshIntervalUpdates, useTimeRangeUpdates } from '../../hooks/use_time_filter'; import { useUrlState } from '../../hooks/use_url_state'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; import { aiopsRefresh$ } from '../../application/services/timefilter_refresh_service'; @@ -67,21 +66,6 @@ function updateLastRefresh(timeRange?: OnRefreshProps) { aiopsRefresh$.next({ lastRefresh: Date.now(), timeRange }); } -export const useRefreshIntervalUpdates = (timefilter: TimefilterContract) => { - return useObservable( - timefilter.getRefreshIntervalUpdate$().pipe(map(timefilter.getRefreshInterval)), - timefilter.getRefreshInterval() - ); -}; - -export const useTimeRangeUpdates = (timefilter: TimefilterContract, absolute = false) => { - const getTimeCallback = absolute - ? timefilter.getAbsoluteTime.bind(timefilter) - : timefilter.getTime.bind(timefilter); - - return useObservable(timefilter.getTimeUpdate$().pipe(map(getTimeCallback)), getTimeCallback()); -}; - export const DatePickerWrapper: FC = () => { const services = useAiopsAppContext(); const { toasts } = services.notifications; @@ -93,8 +77,8 @@ export const DatePickerWrapper: FC = () => { const [globalState, setGlobalState] = useUrlState('_g'); const getRecentlyUsedRanges = getRecentlyUsedRangesFactory(history); - const timeFilterRefreshInterval = useRefreshIntervalUpdates(timefilter); - const time = useTimeRangeUpdates(timefilter); + const timeFilterRefreshInterval = useRefreshIntervalUpdates(); + const time = useTimeRangeUpdates(); useEffect( function syncTimRangeFromUrlState() { @@ -257,13 +241,10 @@ export const DatePickerWrapper: FC = () => { } return isAutoRefreshSelectorEnabled || isTimeRangeSelectorEnabled ? ( - - + + void; + callback?: (a: GetTimeFieldRangeResponse) => void; } const FROZEN_TIER_PREFERENCE = { @@ -44,7 +47,7 @@ const FROZEN_TIER_PREFERENCE = { type FrozenTierPreference = typeof FROZEN_TIER_PREFERENCE[keyof typeof FROZEN_TIER_PREFERENCE]; -export const FullTimeRangeSelector: FC = ({ +export const FullTimeRangeSelector: FC = ({ timefilter, dataView, query, diff --git a/x-pack/plugins/aiops/public/components/page_header/index.ts b/x-pack/plugins/aiops/public/components/page_header/index.ts new file mode 100644 index 0000000000000..a156a49389e9b --- /dev/null +++ b/x-pack/plugins/aiops/public/components/page_header/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { PageHeader } from './page_header'; diff --git a/x-pack/plugins/aiops/public/components/page_header/page_header.tsx b/x-pack/plugins/aiops/public/components/page_header/page_header.tsx new file mode 100644 index 0000000000000..fb45adcc3cde0 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/page_header/page_header.tsx @@ -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 React, { FC, useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiPageContentHeader_Deprecated as EuiPageContentHeader, + EuiPageContentHeaderSection_Deprecated as EuiPageContentHeaderSection, +} from '@elastic/eui'; +import { FullTimeRangeSelectorProps } from '../full_time_range_selector/full_time_range_selector'; +import { useUrlState } from '../../hooks/use_url_state'; +import { useDataSource } from '../../hooks/use_data_source'; +import { useTimefilter } from '../../hooks/use_time_filter'; +import { FullTimeRangeSelector } from '../full_time_range_selector'; +import { DatePickerWrapper } from '../date_picker_wrapper'; + +export const PageHeader: FC = () => { + const [, setGlobalState] = useUrlState('_g'); + const { dataView } = useDataSource(); + + const timefilter = useTimefilter({ + timeRangeSelector: dataView.timeFieldName !== undefined, + autoRefreshSelector: true, + }); + + const updateTimeState: FullTimeRangeSelectorProps['callback'] = useCallback( + (update) => { + setGlobalState({ time: { from: update.start.string, to: update.end.string } }); + }, + [setGlobalState] + ); + + return ( + <> + + + + +
+ +

{dataView.getName()}

+
+
+
+ + + {dataView.timeFieldName !== undefined && ( + + + + )} + + + + +
+
+
+ + + ); +}; diff --git a/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts b/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts index ddbfca3eb8b11..84a0e7283c7c1 100644 --- a/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts +++ b/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts @@ -16,6 +16,7 @@ import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { CoreStart, CoreSetup, HttpStart, IUiSettingsClient } from '@kbn/core/public'; import type { ThemeServiceStart } from '@kbn/core/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; export interface AiopsAppDependencies { application: CoreStart['application']; @@ -29,6 +30,7 @@ export interface AiopsAppDependencies { uiSettings: IUiSettingsClient; unifiedSearch: UnifiedSearchPublicPluginStart; share: SharePluginStart; + lens: LensPublicStart; } export const AiopsAppContext = createContext(undefined); diff --git a/x-pack/plugins/aiops/public/hooks/use_cancellable_search.ts b/x-pack/plugins/aiops/public/hooks/use_cancellable_search.ts new file mode 100644 index 0000000000000..926189a84146b --- /dev/null +++ b/x-pack/plugins/aiops/public/hooks/use_cancellable_search.ts @@ -0,0 +1,67 @@ +/* + * 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 { useCallback, useRef, useState } from 'react'; +import { + type IKibanaSearchResponse, + isCompleteResponse, + isErrorResponse, +} from '@kbn/data-plugin/common'; +import { tap } from 'rxjs/operators'; +import { useAiopsAppContext } from './use_aiops_app_context'; + +export function useCancellableSearch() { + const { data } = useAiopsAppContext(); + const abortController = useRef(new AbortController()); + const [isLoading, setIsFetching] = useState(false); + + const runRequest = useCallback( + ( + requestBody: RequestBody + ): Promise => { + return new Promise((resolve, reject) => { + data.search + .search(requestBody, { + abortSignal: abortController.current.signal, + }) + .pipe( + tap(() => { + setIsFetching(true); + }) + ) + .subscribe({ + next: (result) => { + if (isCompleteResponse(result)) { + setIsFetching(false); + resolve(result); + } else if (isErrorResponse(result)) { + reject(result); + } else { + // partial results + // Ignore partial results for now. + // An issue with the search function means partial results are not being returned correctly. + } + }, + error: (error) => { + if (error.name === 'AbortError') { + return resolve(null); + } + reject(error); + }, + }); + }); + }, + [data.search] + ); + + const cancelRequest = useCallback(() => { + abortController.current.abort(); + abortController.current = new AbortController(); + }, []); + + return { runRequest, cancelRequest, isLoading }; +} diff --git a/x-pack/plugins/aiops/public/hooks/use_data.ts b/x-pack/plugins/aiops/public/hooks/use_data.ts index 11977c2543001..75554d5a9b96e 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data.ts +++ b/x-pack/plugins/aiops/public/hooks/use_data.ts @@ -9,12 +9,11 @@ import { useEffect, useMemo, useState } from 'react'; import { merge } from 'rxjs'; import type { DataView } from '@kbn/data-views-plugin/public'; -import { UI_SETTINGS } from '@kbn/data-plugin/common'; import type { ChangePoint } from '@kbn/ml-agg-utils'; import type { SavedSearch } from '@kbn/discover-plugin/public'; -import { TimeBuckets } from '../../common/time_buckets'; +import { useTimeBuckets } from './use_time_buckets'; import { useAiopsAppContext } from './use_aiops_app_context'; import { aiopsRefresh$ } from '../application/services/timefilter_refresh_service'; @@ -96,14 +95,7 @@ export const useData = ( lastRefresh, ]); - const _timeBuckets = useMemo(() => { - return new TimeBuckets({ - [UI_SETTINGS.HISTOGRAM_MAX_BARS]: uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), - [UI_SETTINGS.HISTOGRAM_BAR_TARGET]: uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), - dateFormat: uiSettings.get('dateFormat'), - 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), - }); - }, [uiSettings]); + const _timeBuckets = useTimeBuckets(); const timefilter = useTimefilter({ timeRangeSelector: currentDataView?.timeFieldName !== undefined, diff --git a/x-pack/plugins/aiops/public/hooks/use_data_source.ts b/x-pack/plugins/aiops/public/hooks/use_data_source.ts new file mode 100644 index 0000000000000..2e887830f7042 --- /dev/null +++ b/x-pack/plugins/aiops/public/hooks/use_data_source.ts @@ -0,0 +1,25 @@ +/* + * 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 { createContext, useContext } from 'react'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { SavedSearch } from '@kbn/saved-search-plugin/public'; +import { SavedSearchSavedObject } from '../application/utils/search_utils'; + +export const DataSourceContext = createContext<{ + dataView: DataView | never; + savedSearch: SavedSearch | SavedSearchSavedObject | null; +}>({ + get dataView(): never { + throw new Error('Context is not implemented'); + }, + savedSearch: null, +}); + +export function useDataSource() { + return useContext(DataSourceContext); +} diff --git a/x-pack/plugins/aiops/public/hooks/use_time_buckets.ts b/x-pack/plugins/aiops/public/hooks/use_time_buckets.ts new file mode 100644 index 0000000000000..345b7d949ab7c --- /dev/null +++ b/x-pack/plugins/aiops/public/hooks/use_time_buckets.ts @@ -0,0 +1,24 @@ +/* + * 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 { useMemo } from 'react'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { TimeBuckets } from '../../common/time_buckets'; +import { useAiopsAppContext } from './use_aiops_app_context'; + +export const useTimeBuckets = () => { + const { uiSettings } = useAiopsAppContext(); + + return useMemo(() => { + return new TimeBuckets({ + [UI_SETTINGS.HISTOGRAM_MAX_BARS]: uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + [UI_SETTINGS.HISTOGRAM_BAR_TARGET]: uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + }, [uiSettings]); +}; diff --git a/x-pack/plugins/aiops/public/hooks/use_time_filter.ts b/x-pack/plugins/aiops/public/hooks/use_time_filter.ts index 4f2a33966f71e..c6026f9f38a55 100644 --- a/x-pack/plugins/aiops/public/hooks/use_time_filter.ts +++ b/x-pack/plugins/aiops/public/hooks/use_time_filter.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { isEqual } from 'lodash'; import { useAiopsAppContext } from './use_aiops_app_context'; interface UseTimefilterOptions { @@ -41,3 +44,31 @@ export const useTimefilter = ({ return timefilter; }; + +export const useRefreshIntervalUpdates = () => { + const timefilter = useTimefilter(); + + const refreshIntervalObservable$ = useMemo( + () => timefilter.getRefreshIntervalUpdate$().pipe(map(timefilter.getRefreshInterval)), + [timefilter] + ); + + return useObservable(refreshIntervalObservable$, timefilter.getRefreshInterval()); +}; + +export const useTimeRangeUpdates = (absolute = false) => { + const timefilter = useTimefilter(); + + const getTimeCallback = useMemo(() => { + return absolute + ? timefilter.getAbsoluteTime.bind(timefilter) + : timefilter.getTime.bind(timefilter); + }, [absolute, timefilter]); + + const timeChangeObservable$ = useMemo( + () => timefilter.getTimeUpdate$().pipe(map(getTimeCallback), distinctUntilChanged(isEqual)), + [timefilter, getTimeCallback] + ); + + return useObservable(timeChangeObservable$, getTimeCallback()); +}; diff --git a/x-pack/plugins/aiops/public/hooks/use_url_state.tsx b/x-pack/plugins/aiops/public/hooks/use_url_state.tsx index e6564ac72f9bf..c4d84163f887e 100644 --- a/x-pack/plugins/aiops/public/hooks/use_url_state.tsx +++ b/x-pack/plugins/aiops/public/hooks/use_url_state.tsx @@ -184,12 +184,12 @@ export const useUrlState = (accessor: Accessor) => { }; export const AppStateKey = 'AIOPS_INDEX_VIEWER'; - +export const ChangePointStateKey = 'changePoint' as const; /** * Hook for managing the URL state of the page. */ export const usePageUrlState = ( - pageKey: typeof AppStateKey, + pageKey: typeof AppStateKey | typeof ChangePointStateKey, defaultState?: PageUrlState ): [PageUrlState, (update: Partial, replaceState?: boolean) => void] => { const [appState, setAppState] = useUrlState('_a'); diff --git a/x-pack/plugins/aiops/public/index.ts b/x-pack/plugins/aiops/public/index.ts index 3cd151ea2b72f..74a70f0acfd27 100755 --- a/x-pack/plugins/aiops/public/index.ts +++ b/x-pack/plugins/aiops/public/index.ts @@ -13,4 +13,8 @@ export function plugin() { return new AiopsPlugin(); } -export { ExplainLogRateSpikes, LogCategorization } from './shared_lazy_components'; +export { + ExplainLogRateSpikes, + LogCategorization, + ChangePointDetection, +} from './shared_lazy_components'; diff --git a/x-pack/plugins/aiops/public/shared_lazy_components.tsx b/x-pack/plugins/aiops/public/shared_lazy_components.tsx index 90d01f999a8b6..a5ad80bd7587e 100644 --- a/x-pack/plugins/aiops/public/shared_lazy_components.tsx +++ b/x-pack/plugins/aiops/public/shared_lazy_components.tsx @@ -15,7 +15,7 @@ const ExplainLogRateSpikesAppStateLazy = React.lazy( () => import('./components/explain_log_rate_spikes') ); -const ExplainLogRateSpikesLazyWrapper: FC = ({ children }) => ( +const LazyWrapper: FC = ({ children }) => ( }>{children} @@ -26,25 +26,30 @@ const ExplainLogRateSpikesLazyWrapper: FC = ({ children }) => ( * @param {ExplainLogRateSpikesAppStateProps} props - properties specifying the data on which to run the analysis. */ export const ExplainLogRateSpikes: FC = (props) => ( - + - + ); const LogCategorizationAppStateLazy = React.lazy(() => import('./components/log_categorization')); -const LogCategorizationLazyWrapper: FC = ({ children }) => ( - - }>{children} - -); - /** * Lazy-wrapped LogCategorizationAppStateProps React component * @param {LogCategorizationAppStateProps} props - properties specifying the data on which to run the analysis. */ export const LogCategorization: FC = (props) => ( - + - + +); + +const ChangePointDetectionLazy = React.lazy(() => import('./components/change_point_detection')); +/** + * Lazy-wrapped LogCategorizationAppStateProps React component + * @param {LogCategorizationAppStateProps} props - properties specifying the data on which to run the analysis. + */ +export const ChangePointDetection: FC = (props) => ( + + + ); diff --git a/x-pack/plugins/aiops/tsconfig.json b/x-pack/plugins/aiops/tsconfig.json index d528e4fa3642d..0f8ba148324bd 100644 --- a/x-pack/plugins/aiops/tsconfig.json +++ b/x-pack/plugins/aiops/tsconfig.json @@ -25,5 +25,6 @@ { "path": "../security/tsconfig.json" }, { "path": "../../../src/plugins/charts/tsconfig.json" }, { "path": "../../../src/plugins/discover/tsconfig.json" }, + { "path": "../lens/tsconfig.json" } ] } diff --git a/x-pack/plugins/ml/common/constants/locator.ts b/x-pack/plugins/ml/common/constants/locator.ts index f4aa35675cfd1..36cdedee82c40 100644 --- a/x-pack/plugins/ml/common/constants/locator.ts +++ b/x-pack/plugins/ml/common/constants/locator.ts @@ -62,6 +62,8 @@ export const ML_PAGES = { AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT: 'aiops/explain_log_rate_spikes_index_select', AIOPS_LOG_CATEGORIZATION: 'aiops/log_categorization', AIOPS_LOG_CATEGORIZATION_INDEX_SELECT: 'aiops/log_categorization_index_select', + AIOPS_CHANGE_POINT_DETECTION: 'aiops/change_point_detection', + AIOPS_CHANGE_POINT_DETECTION_INDEX_SELECT: 'aiops/change_point_detection_index_select', } as const; export type MlPages = typeof ML_PAGES[keyof typeof ML_PAGES]; diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index 973b2bb7335a3..98e2ecb0ede5d 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -65,7 +65,9 @@ export type MlGenericUrlState = MLPageState< | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT | typeof ML_PAGES.AIOPS_LOG_CATEGORIZATION - | typeof ML_PAGES.AIOPS_LOG_CATEGORIZATION_INDEX_SELECT, + | typeof ML_PAGES.AIOPS_LOG_CATEGORIZATION_INDEX_SELECT + | typeof ML_PAGES.AIOPS_CHANGE_POINT_DETECTION_INDEX_SELECT + | typeof ML_PAGES.AIOPS_CHANGE_POINT_DETECTION, MlGenericUrlPageState | undefined >; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 8104d9ef3d51d..c7191118e2599 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -17,6 +17,7 @@ "embeddable", "features", "fieldFormats", + "lens", "licensing", "share", "taskManager", @@ -28,7 +29,6 @@ "alerting", "dashboard", "home", - "lens", "licenseManagement", "management", "maps", diff --git a/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx b/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx new file mode 100644 index 0000000000000..3ee53940add2b --- /dev/null +++ b/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { pick } from 'lodash'; + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; +import { ChangePointDetection } from '@kbn/aiops-plugin/public'; + +import { useMlContext } from '../contexts/ml'; +import { useMlKibana } from '../contexts/kibana'; +import { HelpMenu } from '../components/help_menu'; +import { TechnicalPreviewBadge } from '../components/technical_preview_badge'; + +import { MlPageHeader } from '../components/page_header'; + +export const ChangePointDetectionPage: FC = () => { + const { services } = useMlKibana(); + + const context = useMlContext(); + const dataView = context.currentDataView; + const savedSearch = context.currentSavedSearch; + + return ( + <> + + + + + + + + + + + {dataView ? ( + + ) : null} + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx index 9cfa17fe71941..c7abf7385c3b0 100644 --- a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx +++ b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx @@ -58,6 +58,7 @@ export const ExplainLogRateSpikesPage: FC = () => { 'uiSettings', 'unifiedSearch', 'theme', + 'lens', ])} /> )} diff --git a/x-pack/plugins/ml/public/application/aiops/index.ts b/x-pack/plugins/ml/public/application/aiops/index.ts index fa47ae09822e2..48cb2e9f8cd36 100644 --- a/x-pack/plugins/ml/public/application/aiops/index.ts +++ b/x-pack/plugins/ml/public/application/aiops/index.ts @@ -6,3 +6,4 @@ */ export { ExplainLogRateSpikesPage } from './explain_log_rate_spikes'; +export { ChangePointDetectionPage } from './change_point_detection'; diff --git a/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx b/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx index 3f70e0c58324d..a78a1e3815be8 100644 --- a/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx +++ b/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx @@ -58,6 +58,7 @@ export const LogCategorizationPage: FC = () => { 'uiSettings', 'unifiedSearch', 'theme', + 'lens', ])} /> )} diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 1ef553eae92f7..12bd498c98fa9 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -93,6 +93,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { cases: deps.cases, unifiedSearch: deps.unifiedSearch, licensing: deps.licensing, + lens: deps.lens, ...coreStart, }; diff --git a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx index 6959f5cf17a53..816aec2eec81a 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx @@ -28,6 +28,8 @@ export interface Tab { onClick?: () => Promise; /** Indicates if item should be marked as active with nested routes */ highlightNestedRoutes?: boolean; + /** List of route IDs related to the side nav entry */ + relatedRouteIds?: string[]; } export function useSideNavItems(activeRoute: MlRoute | undefined) { @@ -252,6 +254,7 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { }), disabled: disableLinks, testSubj: 'mlMainTab explainLogRateSpikes', + relatedRouteIds: ['explain_log_rate_spikes'], }, { id: 'logCategorization', @@ -261,6 +264,17 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { }), disabled: disableLinks, testSubj: 'mlMainTab logCategorization', + relatedRouteIds: ['log_categorization'], + }, + { + id: 'changePointDetection', + pathId: ML_PAGES.AIOPS_CHANGE_POINT_DETECTION_INDEX_SELECT, + name: i18n.translate('xpack.ml.navMenu.changePointDetectionLinkText', { + defaultMessage: 'Change Point Detection', + }), + disabled: disableLinks, + testSubj: 'mlMainTab changePointDetection', + relatedRouteIds: ['change_point_detection'], }, ], }); @@ -271,13 +285,24 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { const getTabItem: (tab: Tab) => EuiSideNavItemType = useCallback( (tab: Tab) => { - const { id, disabled, items, onClick, pathId, name, testSubj, highlightNestedRoutes } = tab; + const { + id, + disabled, + items, + onClick, + pathId, + name, + testSubj, + highlightNestedRoutes, + relatedRouteIds, + } = tab; const onClickCallback = onClick ?? (pathId ? redirectToTab.bind(null, pathId) : undefined); const isSelected = `/${pathId}` === activeRoute?.path || - (!!highlightNestedRoutes && activeRoute?.path.includes(`${pathId}/`)); + (!!highlightNestedRoutes && activeRoute?.path.includes(`${pathId}/`)) || + (Array.isArray(relatedRouteIds) && relatedRouteIds.includes(activeRoute?.id!)); return { id, @@ -290,7 +315,7 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { forceOpen: true, }; }, - [activeRoute?.path, redirectToTab] + [activeRoute, redirectToTab] ); return useMemo(() => tabsDefinition.map(getTabItem), [tabsDefinition, getTabItem]); diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index 5b9e2f7d2ab27..d88d9abb24a87 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -24,6 +24,7 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { CasesUiStart } from '@kbn/cases-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; import type { MlServicesContext } from '../../app'; interface StartPlugins { @@ -45,6 +46,7 @@ interface StartPlugins { unifiedSearch: UnifiedSearchPublicPluginStart; core: CoreStart; appName: string; + lens: LensPublicStart; } export type StartServices = CoreStart & StartPlugins & { diff --git a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts index 9ae337cfcd7f0..dc21715d963a8 100644 --- a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts +++ b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts @@ -71,6 +71,13 @@ export const AIOPS_BREADCRUMB_LOG_PATTERN_ANALYSIS: ChromeBreadcrumb = Object.fr href: '/aiops/log_categorization_index_select', }); +export const AIOPS_BREADCRUMB_CHANGE_POINT_DETECTION: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.aiopsBreadcrumbLabel', { + defaultMessage: 'AIOps Labs', + }), + href: '/aiops/change_point_detection_index_select', +}); + export const EXPLAIN_LOG_RATE_SPIKES: ChromeBreadcrumb = Object.freeze({ text: i18n.translate('xpack.ml.aiops.explainLogRateSpikesBreadcrumbLabel', { defaultMessage: 'Explain Log Rate Spikes', @@ -85,6 +92,13 @@ export const LOG_PATTERN_ANALYSIS: ChromeBreadcrumb = Object.freeze({ href: '/aiops/log_categorization_index_select', }); +export const CHANGE_POINT_DETECTION: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.aiops.changePointDetectionBreadcrumbLabel', { + defaultMessage: 'Change Point Detection', + }), + href: '/aiops/change_point_detection_index_select', +}); + export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ text: i18n.translate('xpack.ml.createJobsBreadcrumbLabel', { defaultMessage: 'Create job', @@ -115,8 +129,10 @@ const breadcrumbs = { DATA_VISUALIZER_BREADCRUMB, AIOPS_BREADCRUMB_EXPLAIN_LOG_RATE_SPIKES, AIOPS_BREADCRUMB_LOG_PATTERN_ANALYSIS, + AIOPS_BREADCRUMB_CHANGE_POINT_DETECTION, EXPLAIN_LOG_RATE_SPIKES, LOG_PATTERN_ANALYSIS, + CHANGE_POINT_DETECTION, CREATE_JOB_BREADCRUMB, CALENDAR_MANAGEMENT_BREADCRUMB, FILTER_LISTS_BREADCRUMB, diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx new file mode 100644 index 0000000000000..06e7fc25617de --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx @@ -0,0 +1,55 @@ +/* + * 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 { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; +import { i18n } from '@kbn/i18n'; +import React, { FC } from 'react'; +import { parse } from 'query-string'; +import { NavigateToPath } from '../../../contexts/kibana'; +import { MlRoute } from '../..'; +import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { PageLoader, PageProps } from '../../router'; +import { useResolver } from '../../use_resolver'; +import { checkBasicLicense } from '../../../license'; +import { cacheDataViewsContract } from '../../../util/index_utils'; +import { ChangePointDetectionPage as Page } from '../../../aiops'; + +export const changePointDetectionRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + id: 'change_point_detection', + path: '/aiops/change_point_detection', + title: i18n.translate('xpack.ml.aiops.changePointDetection.docTitle', { + defaultMessage: 'Change point detection', + }), + render: (props, deps) => , + breadcrumbs: [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_CHANGE_POINT_DETECTION', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.aiopsBreadcrumbs.changePointDetectionLabel', { + defaultMessage: 'Change point detection', + }), + }, + ], + disabled: !AIOPS_ENABLED, +}); + +const PageWrapper: FC = ({ location, deps }) => { + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); + const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts index 5b55d41e887bc..92c96f4844326 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts @@ -7,3 +7,4 @@ export * from './explain_log_rate_spikes'; export * from './log_categorization'; +export * from './change_point_detection'; diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx index 9323d86d32b99..b7513db067e2f 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx @@ -55,7 +55,7 @@ const getExplainLogRateSpikesBreadcrumbs = (navigateToPath: NavigateToPath, base getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_EXPLAIN_LOG_RATE_SPIKES', navigateToPath, basePath), getBreadcrumbWithUrlForApp('EXPLAIN_LOG_RATE_SPIKES', navigateToPath, basePath), { - text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDateViewLabel', { + text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDataViewLabel', { defaultMessage: 'Select Data View', }), }, @@ -66,7 +66,18 @@ const getLogCategorizationBreadcrumbs = (navigateToPath: NavigateToPath, basePat getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_LOG_PATTERN_ANALYSIS', navigateToPath, basePath), getBreadcrumbWithUrlForApp('LOG_PATTERN_ANALYSIS', navigateToPath, basePath), { - text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDateViewLabel', { + text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDataViewLabel', { + defaultMessage: 'Select Data View', + }), + }, +]; + +const getChangePointDetectionBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_CHANGE_POINT_DETECTION', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CHANGE_POINT_DETECTION', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDataViewLabel', { defaultMessage: 'Select Data View', }), }, @@ -148,6 +159,26 @@ export const logCategorizationIndexOrSearchRouteFactory = ( breadcrumbs: getLogCategorizationBreadcrumbs(navigateToPath, basePath), }); +export const changePointDetectionIndexOrSearchRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + id: 'data_view_change_point_detection', + path: '/aiops/change_point_detection_index_select', + title: i18n.translate('xpack.ml.selectDataViewLabel', { + defaultMessage: 'Select Data View', + }), + render: (props, deps) => ( + + ), + breadcrumbs: getChangePointDetectionBreadcrumbs(navigateToPath, basePath), +}); + const PageWrapper: FC = ({ nextStepPath, deps, mode }) => { const { services: { diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index a742700cceaec..6860720d45142 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -90,6 +90,8 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT: case ML_PAGES.AIOPS_LOG_CATEGORIZATION: case ML_PAGES.AIOPS_LOG_CATEGORIZATION_INDEX_SELECT: + case ML_PAGES.AIOPS_CHANGE_POINT_DETECTION: + case ML_PAGES.AIOPS_CHANGE_POINT_DETECTION_INDEX_SELECT: case ML_PAGES.OVERVIEW: case ML_PAGES.SETTINGS: case ML_PAGES.FILTER_LISTS_MANAGE: diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index dfa54cb8df3d5..335a7dd4c96c7 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -19475,7 +19475,6 @@ "xpack.ml.aiops.explainLogRateSpikes.docTitle": "Expliquer les pics de taux de log", "xpack.ml.aiopsBreadcrumbLabel": "AIOps", "xpack.ml.aiopsBreadcrumbs.explainLogRateSpikesLabel": "Expliquer les pics de taux de log", - "xpack.ml.aiopsBreadcrumbs.selectDateViewLabel": "Vue de données", "xpack.ml.alertConditionValidation.title": "La condition d'alerte contient les problèmes suivants :", "xpack.ml.alertContext.anomalyExplorerUrlDescription": "URL pour ouvrir dans Anomaly Explorer", "xpack.ml.alertContext.isInterimDescription": "Indique si les premiers résultats contiennent des résultats temporaires", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bc02683bb84d3..909949016dd0e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -19456,7 +19456,6 @@ "xpack.ml.aiops.explainLogRateSpikes.docTitle": "ログレートスパイクを説明", "xpack.ml.aiopsBreadcrumbLabel": "AIOps", "xpack.ml.aiopsBreadcrumbs.explainLogRateSpikesLabel": "ログレートスパイクを説明", - "xpack.ml.aiopsBreadcrumbs.selectDateViewLabel": "データビュー", "xpack.ml.alertConditionValidation.title": "アラート条件には次の問題が含まれます。", "xpack.ml.alertContext.anomalyExplorerUrlDescription": "異常エクスプローラーを開くURL", "xpack.ml.alertContext.isInterimDescription": "上位の一致に中間結果が含まれるかどうかを示します", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 10f0157999908..edfd7f25c30bf 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -19486,7 +19486,6 @@ "xpack.ml.aiops.explainLogRateSpikes.docTitle": "解释日志速率峰值", "xpack.ml.aiopsBreadcrumbLabel": "AIOps", "xpack.ml.aiopsBreadcrumbs.explainLogRateSpikesLabel": "解释日志速率峰值", - "xpack.ml.aiopsBreadcrumbs.selectDateViewLabel": "数据视图", "xpack.ml.alertConditionValidation.title": "告警条件包含以下问题:", "xpack.ml.alertContext.anomalyExplorerUrlDescription": "要在 Anomaly Explorer 中打开的 URL", "xpack.ml.alertContext.isInterimDescription": "表示排名靠前的命中是否包含中间结果", From 063909ba931ea5b2b8d3f884b95d9aab25e1036d Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Tue, 15 Nov 2022 18:54:28 +0100 Subject: [PATCH 02/13] Add useTopNPopOver unit test (#145237) ## Summary Ops, I forgot to add this test to my previous PR. https://github.com/elastic/kibana/pull/144819 --- .../components/hover_actions/utils.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 x-pack/plugins/security_solution/public/common/components/hover_actions/utils.test.ts diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/utils.test.ts b/x-pack/plugins/security_solution/public/common/components/hover_actions/utils.test.ts new file mode 100644 index 0000000000000..4adb83b26cdda --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/utils.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { useTopNPopOver } from './utils'; + +describe('useTopNPopOver', () => { + it('calls setIsPopoverVisible when toggling top N', () => { + const setIsPopoverVisible = jest.fn(); + const { + result: { + current: { toggleTopN }, + }, + } = renderHook(() => useTopNPopOver(setIsPopoverVisible)); + + act(() => { + toggleTopN(); + }); + + expect(setIsPopoverVisible).toHaveBeenCalled(); + }); + + it('sets isShowingTopN to true when toggleTopN is called', () => { + const { result } = renderHook(() => useTopNPopOver()); + + expect(result.current.isShowingTopN).toBeFalsy(); + + act(() => { + result.current.toggleTopN(); + }); + + expect(result.current.isShowingTopN).toBeTruthy(); + }); + + it('sets isShowingTopN to false when toggleTopN is called for the second time', () => { + const { result } = renderHook(() => useTopNPopOver()); + + expect(result.current.isShowingTopN).toBeFalsy(); + + act(() => { + result.current.toggleTopN(); + result.current.toggleTopN(); + }); + + expect(result.current.isShowingTopN).toBeFalsy(); + }); + + it('sets isShowingTopN to false when closeTopN is called', () => { + const { result } = renderHook(() => useTopNPopOver()); + act(() => { + // First, make isShowingTopN truthy. + result.current.toggleTopN(); + }); + expect(result.current.isShowingTopN).toBeTruthy(); + + act(() => { + result.current.closeTopN(); + }); + expect(result.current.isShowingTopN).toBeFalsy(); + }); +}); From 663f3ef4d3d070a3c1f918e289f45e57bfb7c459 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 15 Nov 2022 13:00:19 -0500 Subject: [PATCH 03/13] skip failing test suite (#145270) --- .../api_integration/apis/synthetics/get_monitor_overview.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts b/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts index f700967d060be..575de013a96dd 100644 --- a/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts +++ b/x-pack/test/api_integration/apis/synthetics/get_monitor_overview.ts @@ -13,7 +13,8 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { getFixtureJson } from '../uptime/rest/helper/get_fixture_json'; export default function ({ getService }: FtrProviderContext) { - describe('GetMonitorsOverview', function () { + // Failing: See https://github.com/elastic/kibana/issues/145270 + describe.skip('GetMonitorsOverview', function () { this.tags('skipCloud'); const supertest = getService('supertest'); From efac0215ba3b2510c2ceb4f899fcb7c69ce2db5c Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 15 Nov 2022 19:01:01 +0100 Subject: [PATCH 04/13] [Synthetics] Project monitor delete button (#144984) --- .../monitor_add_edit/form/submit.tsx | 90 +++++++++--- .../management/monitor_list_table/actions.tsx | 91 +++--------- .../management/monitor_list_table/columns.tsx | 2 + .../monitor_list_table/delete_monitor.tsx | 139 ++++++++++++++++++ .../monitor_list/actions.test.tsx | 4 +- .../monitor_list/actions.tsx | 24 +-- .../monitor_list/delete_monitor.test.tsx | 3 +- .../monitor_list/delete_monitor.tsx | 57 ++++--- .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 11 files changed, 278 insertions(+), 141 deletions(-) create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/delete_monitor.tsx diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx index 35e797fe50a23..b1c2fd4ea2e5f 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/submit.tsx @@ -11,9 +11,14 @@ import { EuiButton, EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useFormContext } from 'react-hook-form'; import { useFetcher, FETCH_STATUS } from '@kbn/observability-plugin/public'; -import { SyntheticsMonitor } from '../types'; +import { DeleteMonitor } from '../../monitors_page/management/monitor_list_table/delete_monitor'; +import { ConfigKey, SourceType, SyntheticsMonitor } from '../types'; import { format } from './formatter'; -import { createMonitorAPI, updateMonitorAPI } from '../../../state/monitor_management/api'; +import { + createMonitorAPI, + getMonitorAPI, + updateMonitorAPI, +} from '../../../state/monitor_management/api'; import { kibanaService } from '../../../../../utils/kibana_service'; import { MONITORS_ROUTE, MONITOR_EDIT_ROUTE } from '../../../../../../common/constants'; @@ -28,8 +33,17 @@ export const ActionBar = () => { formState: { errors }, } = useFormContext(); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const [monitorData, setMonitorData] = useState(undefined); + const { data: monitorObject } = useFetcher(() => { + if (isEdit) { + return getMonitorAPI({ id: monitorId }); + } + return undefined; + }, []); + const { data, status } = useFetcher(() => { if (!monitorData) { return null; @@ -71,26 +85,51 @@ export const ActionBar = () => { return status === FETCH_STATUS.SUCCESS ? ( ) : ( - - - {CANCEL_LABEL} - - - - - - {isEdit ? UPDATE_MONITOR_LABEL : CREATE_MONITOR_LABEL} - - - - - + <> + + + {isEdit && ( +
+ { + setIsDeleteModalVisible(true); + }} + > + {DELETE_MONITOR_LABEL} + +
+ )} +
+ + {CANCEL_LABEL} + + + + {isEdit ? UPDATE_MONITOR_LABEL : CREATE_MONITOR_LABEL} + + +
+ {isDeleteModalVisible && ( + { + history.push(MONITORS_ROUTE); + }} + isProjectMonitor={ + monitorObject?.attributes?.[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT + } + setIsDeleteModalVisible={setIsDeleteModalVisible} + /> + )} + ); }; @@ -105,6 +144,13 @@ const CREATE_MONITOR_LABEL = i18n.translate( } ); +const DELETE_MONITOR_LABEL = i18n.translate( + 'xpack.synthetics.monitorManagement.addEdit.deleteMonitorLabel', + { + defaultMessage: 'Delete monitor', + } +); + const UPDATE_MONITOR_LABEL = i18n.translate( 'xpack.synthetics.monitorManagement.updateMonitorLabel', { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/actions.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/actions.tsx index aeb5010f3496e..274d0a9d28317 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/actions.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/actions.tsx @@ -5,19 +5,10 @@ * 2.0. */ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext, useState } from 'react'; import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types'; -import { toMountPoint } from '@kbn/kibana-react-plugin/public'; -import { FETCH_STATUS, useFetcher } from '@kbn/observability-plugin/public'; -import { - EuiContextMenuPanel, - EuiContextMenuItem, - EuiPopover, - EuiButtonEmpty, - EuiConfirmModal, -} from '@elastic/eui'; -import { kibanaService } from '../../../../../../utils/kibana_service'; -import { fetchDeleteMonitor } from '../../../../state'; +import { EuiContextMenuPanel, EuiContextMenuItem, EuiPopover, EuiButtonEmpty } from '@elastic/eui'; +import { DeleteMonitor } from './delete_monitor'; import { SyntheticsSettingsContext } from '../../../../contexts/synthetics_settings_context'; import * as labels from './labels'; @@ -27,54 +18,23 @@ interface Props { id: string; name: string; canEditSynthetics: boolean; + isProjectMonitor?: boolean; reloadPage: () => void; } -export const Actions = ({ euiTheme, id, name, reloadPage, canEditSynthetics }: Props) => { +export const Actions = ({ + euiTheme, + id, + name, + reloadPage, + canEditSynthetics, + isProjectMonitor, +}: Props) => { const { basePath } = useContext(SyntheticsSettingsContext); const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); - const { status: monitorDeleteStatus } = useFetcher(() => { - if (isDeleting) { - return fetchDeleteMonitor({ id }); - } - }, [id, isDeleting]); - // TODO: Move deletion logic to redux state - useEffect(() => { - if (!isDeleting) { - return; - } - if ( - monitorDeleteStatus === FETCH_STATUS.SUCCESS || - monitorDeleteStatus === FETCH_STATUS.FAILURE - ) { - setIsDeleting(false); - setIsDeleteModalVisible(false); - } - if (monitorDeleteStatus === FETCH_STATUS.FAILURE) { - kibanaService.toasts.addDanger( - { - title: toMountPoint( -

{labels.MONITOR_DELETE_FAILURE_LABEL}

- ), - }, - { toastLifeTimeMs: 3000 } - ); - } else if (monitorDeleteStatus === FETCH_STATUS.SUCCESS) { - reloadPage(); - kibanaService.toasts.addSuccess( - { - title: toMountPoint( -

{labels.MONITOR_DELETE_SUCCESS_LABEL}

- ), - }, - { toastLifeTimeMs: 3000 } - ); - } - }, [setIsDeleting, isDeleting, reloadPage, monitorDeleteStatus]); const openPopover = () => { setIsPopoverOpen(true); @@ -89,10 +49,6 @@ export const Actions = ({ euiTheme, id, name, reloadPage, canEditSynthetics }: P closePopover(); }; - const handleConfirmDelete = () => { - setIsDeleting(true); - }; - const menuButton = ( - {isDeleteModalVisible ? ( - setIsDeleteModalVisible(false)} - onConfirm={handleConfirmDelete} - cancelButtonText={labels.NO_LABEL} - confirmButtonText={labels.YES_LABEL} - buttonColor="danger" - defaultFocusedButton="confirm" - isLoading={isDeleting} - > -

{labels.DELETE_DESCRIPTION_LABEL}

-
- ) : null} + {isDeleteModalVisible && ( + + )} ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx index 14aaf8c15d358..e847fdd5c2400 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/columns.tsx @@ -18,6 +18,7 @@ import { EncryptedSyntheticsSavedMonitor, Ping, ServiceLocations, + SourceType, SyntheticsMonitorSchedule, } from '../../../../../../../common/runtime_types'; @@ -155,6 +156,7 @@ export function getMonitorListColumns({ name={fields[ConfigKey.NAME]} canEditSynthetics={canEditSynthetics} reloadPage={reloadPage} + isProjectMonitor={fields[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT} /> ), }, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/delete_monitor.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/delete_monitor.tsx new file mode 100644 index 0000000000000..4dd4e5a0b19a1 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/delete_monitor.tsx @@ -0,0 +1,139 @@ +/* + * 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, { useEffect, useState } from 'react'; +import { EuiCallOut, EuiConfirmModal, EuiLink, EuiSpacer } from '@elastic/eui'; +import { FETCH_STATUS, useFetcher } from '@kbn/observability-plugin/public'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { i18n } from '@kbn/i18n'; + +import { FormattedMessage } from '@kbn/i18n-react'; +import { fetchDeleteMonitor } from '../../../../state'; +import { kibanaService } from '../../../../../../utils/kibana_service'; +import * as labels from './labels'; + +export const DeleteMonitor = ({ + id, + name, + reloadPage, + isProjectMonitor, + setIsDeleteModalVisible, +}: { + id: string; + name: string; + reloadPage: () => void; + isProjectMonitor?: boolean; + setIsDeleteModalVisible: React.Dispatch>; +}) => { + const [isDeleting, setIsDeleting] = useState(false); + + const handleConfirmDelete = () => { + setIsDeleting(true); + }; + + const { status: monitorDeleteStatus } = useFetcher(() => { + if (isDeleting) { + return fetchDeleteMonitor({ id }); + } + }, [id, isDeleting]); + + useEffect(() => { + if (!isDeleting) { + return; + } + if (monitorDeleteStatus === FETCH_STATUS.FAILURE) { + kibanaService.toasts.addDanger( + { + title: toMountPoint( +

{labels.MONITOR_DELETE_FAILURE_LABEL}

+ ), + }, + { toastLifeTimeMs: 3000 } + ); + } else if (monitorDeleteStatus === FETCH_STATUS.SUCCESS) { + reloadPage(); + kibanaService.toasts.addSuccess( + { + title: toMountPoint( +

+ {i18n.translate( + 'xpack.synthetics.monitorManagement.monitorDeleteSuccessMessage.name', + { + defaultMessage: 'Monitor {name} deleted successfully.', + values: { name }, + } + )} +

+ ), + }, + { toastLifeTimeMs: 3000 } + ); + } + if ( + monitorDeleteStatus === FETCH_STATUS.SUCCESS || + monitorDeleteStatus === FETCH_STATUS.FAILURE + ) { + setIsDeleting(false); + setIsDeleteModalVisible(false); + } + }, [setIsDeleting, isDeleting, reloadPage, monitorDeleteStatus, setIsDeleteModalVisible, name]); + + return ( + setIsDeleteModalVisible(false)} + onConfirm={handleConfirmDelete} + cancelButtonText={labels.NO_LABEL} + confirmButtonText={labels.YES_LABEL} + buttonColor="danger" + defaultFocusedButton="confirm" + isLoading={isDeleting} + > + {isProjectMonitor && ( + <> + +

+ +

+
+ + + )} +
+ ); +}; + +export const PROJECT_MONITOR_TITLE = i18n.translate( + 'xpack.synthetics.monitorManagement.monitorList.disclaimer.title', + { + defaultMessage: "Deleting this monitor will not remove it from Project's source", + } +); + +export const ProjectMonitorDisclaimer = () => { + return ( + + {i18n.translate('xpack.synthetics.monitorManagement.projectDelete.docsLink', { + defaultMessage: 'read our docs', + })} + + ), + }} + /> + ); +}; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.test.tsx index 930d89f97af2a..4c3d80a4d68cd 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.test.tsx @@ -46,7 +46,7 @@ describe('', () => { ); }); - it('disables deleting for project monitors', () => { + it('allows deleting for project monitors', () => { render( ', () => { /> ); - expect(screen.getByLabelText('Delete monitor')).toBeDisabled(); + expect(screen.getByLabelText('Delete monitor')).not.toBeDisabled(); }); }); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.tsx index a21dedce9043a..78e46f6a0fea8 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.tsx @@ -74,23 +74,13 @@ export const Actions = ({
- - - + {errorSummary && ( diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/delete_monitor.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/delete_monitor.test.tsx index e708be5c8921f..5915aa27e6df5 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/delete_monitor.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/delete_monitor.test.tsx @@ -19,6 +19,7 @@ import { MonitorManagementListResult, SourceType, } from '../../../../../common/runtime_types'; +import userEvent from '@testing-library/user-event'; describe('', () => { const onUpdate = jest.fn(); @@ -61,7 +62,7 @@ describe('', () => { /> ); - fireEvent.click(screen.getByLabelText('Delete monitor')); + userEvent.click(screen.getByTestId('monitorManagementDeleteMonitor')); expect(onUpdate).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/delete_monitor.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/delete_monitor.tsx index 7e3735834644e..68925a72f78d4 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/delete_monitor.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/delete_monitor.tsx @@ -7,10 +7,20 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; -import { EuiButtonIcon, EuiConfirmModal, EuiLoadingSpinner } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiCallOut, + EuiConfirmModal, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; import { FETCH_STATUS, useFetcher } from '@kbn/observability-plugin/public'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { + ProjectMonitorDisclaimer, + PROJECT_MONITOR_TITLE, +} from '../../../../apps/synthetics/components/monitors_page/management/monitor_list_table/delete_monitor'; import { deleteMonitor } from '../../../state/api'; import { kibanaService } from '../../../state/kibana_service'; @@ -19,10 +29,12 @@ export const DeleteMonitor = ({ name, onUpdate, isDisabled, + isProjectMonitor, }: { configId: string; name: string; isDisabled?: boolean; + isProjectMonitor?: boolean; onUpdate: () => void; }) => { const [isDeleting, setIsDeleting] = useState(false); @@ -62,17 +74,28 @@ export const DeleteMonitor = ({ kibanaService.toasts.addSuccess( { title: toMountPoint( -

{MONITOR_DELETE_SUCCESS_LABEL}

+

+ {i18n.translate( + 'xpack.synthetics.monitorManagement.monitorDeleteSuccessMessage.name', + { + defaultMessage: 'Monitor {name} deleted successfully.', + values: { name }, + } + )} +

), }, { toastLifeTimeMs: 3000 } ); } - }, [setIsDeleting, onUpdate, status]); + }, [setIsDeleting, onUpdate, status, name]); const destroyModal = ( setIsDeleteModalVisible(false)} onConfirm={onConfirmDelete} cancelButtonText={NO_LABEL} @@ -80,7 +103,16 @@ export const DeleteMonitor = ({ buttonColor="danger" defaultFocusedButton="confirm" > -

{DELETE_DESCRIPTION_LABEL}

+ {isProjectMonitor && ( + <> + +

+ +

+
+ + + )}
); @@ -102,14 +134,6 @@ export const DeleteMonitor = ({ ); }; -const DELETE_DESCRIPTION_LABEL = i18n.translate( - 'xpack.synthetics.monitorManagement.confirmDescriptionLabel', - { - defaultMessage: - 'This action will delete the monitor but keep any data collected. This action cannot be undone.', - } -); - const YES_LABEL = i18n.translate('xpack.synthetics.monitorManagement.yesLabel', { defaultMessage: 'Delete', }); @@ -125,13 +149,6 @@ const DELETE_MONITOR_LABEL = i18n.translate( } ); -const MONITOR_DELETE_SUCCESS_LABEL = i18n.translate( - 'xpack.synthetics.monitorManagement.monitorDeleteSuccessMessage', - { - defaultMessage: 'Monitor deleted successfully.', - } -); - // TODO: Discuss error states with product const MONITOR_DELETE_FAILURE_LABEL = i18n.translate( 'xpack.synthetics.monitorManagement.monitorDeleteFailureMessage', diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 335a7dd4c96c7..4150391405e79 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -31036,7 +31036,6 @@ "xpack.synthetics.monitorManagement.closeButtonLabel": "Fermer", "xpack.synthetics.monitorManagement.closeLabel": "Fermer", "xpack.synthetics.monitorManagement.completed": "TERMINÉ", - "xpack.synthetics.monitorManagement.confirmDescriptionLabel": "Cette action supprimera le moniteur mais conservera toute donnée collectée. Cette action ne peut pas être annulée.", "xpack.synthetics.monitorManagement.createAgentPolicy": "Créer une stratégie d'agent", "xpack.synthetics.monitorManagement.createMonitorLabel": "Créer le moniteur", "xpack.synthetics.monitorManagement.delete": "Supprimer l’emplacement", @@ -31097,12 +31096,10 @@ "xpack.synthetics.monitorManagement.monitorAdvancedOptions.namespaceHelpLearnMoreLabel": "En savoir plus", "xpack.synthetics.monitorManagement.monitorDeleteFailureMessage": "Impossible de supprimer le moniteur. Réessayez plus tard.", "xpack.synthetics.monitorManagement.monitorDeleteLoadingMessage": "Suppression du moniteur...", - "xpack.synthetics.monitorManagement.monitorDeleteSuccessMessage": "Moniteur supprimé.", "xpack.synthetics.monitorManagement.monitorEditedSuccessMessage": "Moniteur mis à jour.", "xpack.synthetics.monitorManagement.monitorFailureMessage": "Impossible d'enregistrer le moniteur. Réessayez plus tard.", "xpack.synthetics.monitorManagement.monitorList.actions": "Actions", "xpack.synthetics.monitorManagement.monitorList.enabled": "Activé", - "xpack.synthetics.monitorManagement.monitorList.enabled.tooltip": "Ce moniteur a été ajouté depuis un projet externe. Pour supprimer le moniteur, retirez-le du projet et retransmettez la configuration.", "xpack.synthetics.monitorManagement.monitorList.locations": "Emplacements", "xpack.synthetics.monitorManagement.monitorList.monitorName": "Nom de moniteur", "xpack.synthetics.monitorManagement.monitorList.monitorType": "Type de moniteur", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 909949016dd0e..84bd08f5ecc0c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -31012,7 +31012,6 @@ "xpack.synthetics.monitorManagement.closeButtonLabel": "閉じる", "xpack.synthetics.monitorManagement.closeLabel": "閉じる", "xpack.synthetics.monitorManagement.completed": "完了", - "xpack.synthetics.monitorManagement.confirmDescriptionLabel": "このアクションにより、モニターが削除されますが、収集されたデータはすべて保持されます。この操作は元に戻すことができません。", "xpack.synthetics.monitorManagement.createAgentPolicy": "エージェントポリシーを作成", "xpack.synthetics.monitorManagement.createMonitorLabel": "監視の作成", "xpack.synthetics.monitorManagement.delete": "場所を削除", @@ -31073,12 +31072,10 @@ "xpack.synthetics.monitorManagement.monitorAdvancedOptions.namespaceHelpLearnMoreLabel": "詳細情報", "xpack.synthetics.monitorManagement.monitorDeleteFailureMessage": "モニターを削除できませんでした。しばらくたってから再試行してください。", "xpack.synthetics.monitorManagement.monitorDeleteLoadingMessage": "モニターを削除しています...", - "xpack.synthetics.monitorManagement.monitorDeleteSuccessMessage": "モニターが正常に削除されました。", "xpack.synthetics.monitorManagement.monitorEditedSuccessMessage": "モニターは正常に更新されました。", "xpack.synthetics.monitorManagement.monitorFailureMessage": "モニターを保存できませんでした。しばらくたってから再試行してください。", "xpack.synthetics.monitorManagement.monitorList.actions": "アクション", "xpack.synthetics.monitorManagement.monitorList.enabled": "有効", - "xpack.synthetics.monitorManagement.monitorList.enabled.tooltip": "この監視は外部プロジェクトから追加されました。モニターを削除するには、プロジェクトから削除し、もう一度構成をプッシュします。", "xpack.synthetics.monitorManagement.monitorList.locations": "場所", "xpack.synthetics.monitorManagement.monitorList.monitorName": "モニター名", "xpack.synthetics.monitorManagement.monitorList.monitorType": "モニタータイプ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index edfd7f25c30bf..425be938a865e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -31047,7 +31047,6 @@ "xpack.synthetics.monitorManagement.closeButtonLabel": "关闭", "xpack.synthetics.monitorManagement.closeLabel": "关闭", "xpack.synthetics.monitorManagement.completed": "已完成", - "xpack.synthetics.monitorManagement.confirmDescriptionLabel": "此操作将删除监测,但会保留收集的任何数据。此操作无法撤消。", "xpack.synthetics.monitorManagement.createAgentPolicy": "创建代理策略", "xpack.synthetics.monitorManagement.createMonitorLabel": "创建监测", "xpack.synthetics.monitorManagement.delete": "删除位置", @@ -31108,12 +31107,10 @@ "xpack.synthetics.monitorManagement.monitorAdvancedOptions.namespaceHelpLearnMoreLabel": "了解详情", "xpack.synthetics.monitorManagement.monitorDeleteFailureMessage": "无法删除监测。请稍后重试。", "xpack.synthetics.monitorManagement.monitorDeleteLoadingMessage": "正在删除监测......", - "xpack.synthetics.monitorManagement.monitorDeleteSuccessMessage": "已成功删除监测。", "xpack.synthetics.monitorManagement.monitorEditedSuccessMessage": "已成功更新监测。", "xpack.synthetics.monitorManagement.monitorFailureMessage": "无法保存监测。请稍后重试。", "xpack.synthetics.monitorManagement.monitorList.actions": "操作", "xpack.synthetics.monitorManagement.monitorList.enabled": "已启用", - "xpack.synthetics.monitorManagement.monitorList.enabled.tooltip": "已从外部项目添加此监测。要删除监测,请将其从项目中移除,然后再次推送配置。", "xpack.synthetics.monitorManagement.monitorList.locations": "位置", "xpack.synthetics.monitorManagement.monitorList.monitorName": "监测名称", "xpack.synthetics.monitorManagement.monitorList.monitorType": "监测类型", From 882a1a1916591b90cb0486ea2b81cca17eac8ec9 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 15 Nov 2022 19:03:22 +0100 Subject: [PATCH 05/13] [Uptime] Allow using AND for tags filtering (#145079) Co-authored-by: Abdul Zahid Fixes https://github.com/elastic/kibana/issues/132308 --- .../field_value_selection.tsx | 63 ++++++++++++++- .../shared/field_value_suggestions/index.tsx | 4 + .../shared/field_value_suggestions/types.ts | 5 +- .../stringify_kueries.test.ts.snap | 19 ----- .../combine_filters_and_user_search.test.ts | 2 +- .../lib/combine_filters_and_user_search.ts | 2 +- .../common/lib/stringify_kueries.test.ts | 76 ++++++++++++++++--- .../common/lib/stringify_kueries.ts | 51 ++++++++----- .../alerts/alerts_containers/use_snap_shot.ts | 6 +- .../overview/filter_group/filter_group.tsx | 20 ++++- .../overview/query_bar/use_query_bar.test.tsx | 4 +- .../overview/query_bar/use_query_bar.ts | 12 +-- .../public/legacy_uptime/hooks/index.ts | 2 +- ...y_string.ts => use_update_kuery_string.ts} | 38 ++++++++-- 14 files changed, 218 insertions(+), 86 deletions(-) delete mode 100644 x-pack/plugins/synthetics/common/lib/__snapshots__/stringify_kueries.test.ts.snap rename x-pack/plugins/synthetics/public/legacy_uptime/hooks/{update_kuery_string.ts => use_update_kuery_string.ts} (60%) diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx index c7fb176bbb65d..373b57e0e61af 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx @@ -9,6 +9,8 @@ import React, { FormEvent, useEffect, useState } from 'react'; import { EuiText, EuiButton, + EuiSwitch, + EuiSpacer, EuiFilterButton, EuiPopover, EuiPopoverFooter, @@ -16,6 +18,7 @@ import { EuiSelectable, EuiSelectableOption, EuiLoadingSpinner, + useEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; @@ -72,14 +75,24 @@ export function FieldValueSelection({ excludedValue, allowExclusions = true, compressed = true, + useLogicalAND, + showLogicalConditionSwitch = false, onChange: onSelectionChange, }: FieldValueSelectionProps) { + const { euiTheme } = useEuiTheme(); + const [options, setOptions] = useState(() => formatOptions(values, selectedValue, excludedValue, showCount) ); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isLogicalAND, setIsLogicalAND] = useState(useLogicalAND); + + useEffect(() => { + setIsLogicalAND(useLogicalAND); + }, [useLogicalAND]); + useEffect(() => { setOptions(formatOptions(values, selectedValue, excludedValue, showCount)); }, [values, selectedValue, showCount, excludedValue]); @@ -143,7 +156,13 @@ export function FieldValueSelection({ .filter((opt) => opt?.checked === 'off') .map(({ label: labelN }) => labelN); - return isEqual(selectedValue ?? [], currSelected) && isEqual(excludedValue ?? [], currExcluded); + const hasFilterSelected = (selectedValue ?? []).length > 0 || (excludedValue ?? []).length > 0; + + return ( + isEqual(selectedValue ?? [], currSelected) && + isEqual(excludedValue ?? [], currExcluded) && + !(isLogicalAND !== useLogicalAND && hasFilterSelected) + ); }; return ( @@ -190,6 +209,34 @@ export function FieldValueSelection({ )} + {showLogicalConditionSwitch && ( + <> + +
+ { + setIsLogicalAND(e.target.checked); + }} + /> +
+ + + )} + opt?.checked === 'on'); const excludedValuesN = options.filter((opt) => opt?.checked === 'off'); - onSelectionChange(map(selectedValuesN, 'label'), map(excludedValuesN, 'label')); + if (showLogicalConditionSwitch) { + onSelectionChange( + map(selectedValuesN, 'label'), + map(excludedValuesN, 'label'), + isLogicalAND + ); + } else { + onSelectionChange( + map(selectedValuesN, 'label'), + map(excludedValuesN, 'label') + ); + } + setIsPopoverOpen(false); setForceOpen?.(false); }} diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx index 2cd8487c57048..ea414260a4380 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx @@ -36,6 +36,8 @@ export function FieldValueSuggestions({ inspector, asCombobox = true, keepHistory = true, + showLogicalConditionSwitch, + useLogicalAND, onChange: onSelectionChange, }: FieldValueSuggestionsProps) { const [query, setQuery] = useState(''); @@ -77,6 +79,8 @@ export function FieldValueSuggestions({ allowExclusions={allowExclusions} allowAllValuesSelection={singleSelection ? false : allowAllValuesSelection} required={required} + showLogicalConditionSwitch={showLogicalConditionSwitch} + useLogicalAND={useLogicalAND} /> ); } diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts index 59b352c27e5d8..51e89570bc241 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts @@ -30,13 +30,15 @@ interface CommonProps { cardinalityField?: string; required?: boolean; keepHistory?: boolean; + showLogicalConditionSwitch?: boolean; + useLogicalAND?: boolean; + onChange: (val?: string[], excludedValue?: string[], isLogicalAND?: boolean) => void; } export type FieldValueSuggestionsProps = CommonProps & { dataViewTitle?: string; sourceField: string; asCombobox?: boolean; - onChange: (val?: string[], excludedValue?: string[]) => void; filters: ESFilter[]; time?: { from: string; to: string }; inspector?: IInspectorInfo; @@ -44,7 +46,6 @@ export type FieldValueSuggestionsProps = CommonProps & { export type FieldValueSelectionProps = CommonProps & { loading?: boolean; - onChange: (val?: string[], excludedValue?: string[]) => void; values?: ListItem[]; query?: string; setQuery: Dispatch>; diff --git a/x-pack/plugins/synthetics/common/lib/__snapshots__/stringify_kueries.test.ts.snap b/x-pack/plugins/synthetics/common/lib/__snapshots__/stringify_kueries.test.ts.snap deleted file mode 100644 index 21695c8ffee03..0000000000000 --- a/x-pack/plugins/synthetics/common/lib/__snapshots__/stringify_kueries.test.ts.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`stringifyKueries adds quotations if the value contains a space 1`] = `"(foo:fooValue1 or foo:fooValue2) and bar:barValue and baz:\\"baz value\\""`; - -exports[`stringifyKueries adds quotations inside parens if there are values containing spaces 1`] = `"(foo:\\"foo value 1\\" or foo:\\"foo value 2\\") and bar:barValue"`; - -exports[`stringifyKueries correctly stringifies a single value 1`] = `"foo:fooValue"`; - -exports[`stringifyKueries handles colon characters in values 1`] = `"(foo:fooValue1 or foo:fooValue2) and bar:barValue and (monitor.id:\\"https://elastic.co\\" or monitor.id:\\"https://example.com\\")"`; - -exports[`stringifyKueries handles number values 1`] = `"(foo:fooValue1 or foo:fooValue2) and bar:barValue and (port:80 or port:8080 or port:443)"`; - -exports[`stringifyKueries handles parens for values with greater than 2 items 1`] = `"(foo:val1 or foo:val2 or foo:val3) and bar:barValue and (baz:\\"baz 1\\" or baz:\\"baz 2\\")"`; - -exports[`stringifyKueries returns an empty string for an empty map 1`] = `""`; - -exports[`stringifyKueries returns an empty string for an empty value 1`] = `"aField:"`; - -exports[`stringifyKueries stringifies the current values 1`] = `"(foo:fooValue1 or foo:fooValue2) and bar:barValue"`; diff --git a/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.test.ts b/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.test.ts index dda3717cb7512..93b954e1a23fa 100644 --- a/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.test.ts +++ b/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.test.ts @@ -18,7 +18,7 @@ describe('combineFiltersAndUserSearch', () => { it('returns merged filters and user search if neither is empty', () => { expect(combineFiltersAndUserSearch('monitor.id:foo', 'monitor.name:bar')).toEqual( - '(monitor.id:foo) and (monitor.name:bar)' + '(monitor.id:foo) AND (monitor.name:bar)' ); }); }); diff --git a/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.ts b/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.ts index 30a6601f1b630..a890207867b2a 100644 --- a/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.ts +++ b/x-pack/plugins/synthetics/common/lib/combine_filters_and_user_search.ts @@ -11,5 +11,5 @@ export const combineFiltersAndUserSearch = (filters: string, search: string) => } if (!filters) return search; if (!search) return filters; - return `(${filters}) and (${search})`; + return `(${filters}) AND (${search})`; }; diff --git a/x-pack/plugins/synthetics/common/lib/stringify_kueries.test.ts b/x-pack/plugins/synthetics/common/lib/stringify_kueries.test.ts index f3fc3d3c95cfb..e37b69eee5bf5 100644 --- a/x-pack/plugins/synthetics/common/lib/stringify_kueries.test.ts +++ b/x-pack/plugins/synthetics/common/lib/stringify_kueries.test.ts @@ -16,49 +16,61 @@ describe('stringifyKueries', () => { }); it('stringifies the current values', () => { - expect(stringifyKueries(kueries)).toMatchSnapshot(); + expect(stringifyKueries(kueries)).toMatchInlineSnapshot( + `"foo: (fooValue1 OR fooValue2) AND bar: barValue"` + ); }); it('correctly stringifies a single value', () => { kueries = new Map(); kueries.set('foo', ['fooValue']); - expect(stringifyKueries(kueries)).toMatchSnapshot(); + expect(stringifyKueries(kueries)).toMatchInlineSnapshot(`"foo: fooValue"`); }); it('returns an empty string for an empty map', () => { - expect(stringifyKueries(new Map())).toMatchSnapshot(); + expect(stringifyKueries(new Map())).toMatchInlineSnapshot(`""`); }); it('returns an empty string for an empty value', () => { kueries = new Map(); kueries.set('aField', ['']); - expect(stringifyKueries(kueries)).toMatchSnapshot(); + expect(stringifyKueries(kueries)).toMatchInlineSnapshot(`""`); }); it('adds quotations if the value contains a space', () => { kueries.set('baz', ['baz value']); - expect(stringifyKueries(kueries)).toMatchSnapshot(); + expect(stringifyKueries(kueries)).toMatchInlineSnapshot( + `"foo: (fooValue1 OR fooValue2) AND bar: barValue AND baz: \\"baz value\\""` + ); }); it('adds quotations inside parens if there are values containing spaces', () => { kueries.set('foo', ['foo value 1', 'foo value 2']); - expect(stringifyKueries(kueries)).toMatchSnapshot(); + expect(stringifyKueries(kueries)).toMatchInlineSnapshot( + `"foo: (\\"foo value 1\\" OR \\"foo value 2\\") AND bar: barValue"` + ); }); it('handles parens for values with greater than 2 items', () => { kueries.set('foo', ['val1', 'val2', 'val3']); kueries.set('baz', ['baz 1', 'baz 2']); - expect(stringifyKueries(kueries)).toMatchSnapshot(); + expect(stringifyKueries(kueries)).toMatchInlineSnapshot( + `"foo: (val1 OR val2 OR val3) AND bar: barValue AND baz: (\\"baz 1\\" OR \\"baz 2\\")"` + ); }); it('handles number values', () => { kueries.set('port', [80, 8080, 443]); - expect(stringifyKueries(kueries)).toMatchSnapshot(); + expect(stringifyKueries(kueries)).toMatchInlineSnapshot( + `"foo: (fooValue1 OR fooValue2) AND bar: barValue AND port: (80 OR 8080 OR 443)"` + ); }); it('handles colon characters in values', () => { kueries.set('monitor.id', ['https://elastic.co', 'https://example.com']); - expect(stringifyKueries(kueries)).toMatchSnapshot(); + expect(stringifyKueries(kueries)).toMatchInlineSnapshot( + `"foo: (fooValue1 OR fooValue2) AND bar: barValue AND monitor.id: (\\"https://elastic.co\\" OR \\"https://example.com\\")"` + ); }); it('handles precending empty array', () => { @@ -71,21 +83,61 @@ describe('stringifyKueries', () => { }) ); expect(stringifyKueries(kueries)).toMatchInlineSnapshot( - `"(observer.geo.name:us-east or observer.geo.name:apj or observer.geo.name:sydney or observer.geo.name:us-west)"` + `"observer.geo.name: (us-east OR apj OR sydney OR us-west)"` ); }); it('handles skipped empty arrays', () => { kueries = new Map( Object.entries({ - tags: [], + tags: ['tag1', 'tag2'], 'monitor.type': ['http'], 'url.port': [], 'observer.geo.name': ['us-east', 'apj', 'sydney', 'us-west'], }) ); expect(stringifyKueries(kueries)).toMatchInlineSnapshot( - `"monitor.type:http and (observer.geo.name:us-east or observer.geo.name:apj or observer.geo.name:sydney or observer.geo.name:us-west)"` + `"tags: (tag1 OR tag2) AND monitor.type: http AND observer.geo.name: (us-east OR apj OR sydney OR us-west)"` + ); + }); + + it('handles tags AND logic', () => { + kueries = new Map( + Object.entries({ + 'monitor.type': ['http'], + 'url.port': [], + 'observer.geo.name': ['us-east', 'apj', 'sydney', 'us-west'], + tags: ['tag1', 'tag2'], + }) + ); + expect(stringifyKueries(kueries, true)).toMatchInlineSnapshot( + `"monitor.type: http AND observer.geo.name: (us-east OR apj OR sydney OR us-west) AND tags: (tag1 AND tag2)"` + ); + }); + + it('handles tags AND logic with only tags', () => { + kueries = new Map( + Object.entries({ + 'monitor.type': [], + 'url.port': [], + 'observer.geo.name': [], + tags: ['tag1', 'tag2'], + }) + ); + expect(stringifyKueries(kueries, true)).toMatchInlineSnapshot(`"tags: (tag1 AND tag2)"`); + }); + + it('handles values with spaces', () => { + kueries = new Map( + Object.entries({ + tags: ['Weird tag'], + 'monitor.type': ['http'], + 'url.port': [], + 'observer.geo.name': ['us east', 'apj', 'sydney', 'us-west'], + }) + ); + expect(stringifyKueries(kueries)).toMatchInlineSnapshot( + `"tags: \\"Weird tag\\" AND monitor.type: http AND observer.geo.name: (\\"us east\\" OR apj OR sydney OR us-west)"` ); }); }); diff --git a/x-pack/plugins/synthetics/common/lib/stringify_kueries.ts b/x-pack/plugins/synthetics/common/lib/stringify_kueries.ts index 86497f63864f5..3860dcd848521 100644 --- a/x-pack/plugins/synthetics/common/lib/stringify_kueries.ts +++ b/x-pack/plugins/synthetics/common/lib/stringify_kueries.ts @@ -10,28 +10,31 @@ * The strings contain all of the values chosen for the given field (which is also the key value). * Reduce the list of query strings to a singular string, with AND operators between. */ -export const stringifyKueries = (kueries: Map>): string => - Array.from(kueries.keys()) + +export const stringifyKueries = ( + kueries: Map>, + logicalANDForTag?: boolean +): string => { + const defaultCondition = 'OR'; + + return Array.from(kueries.keys()) .map((key) => { - const value = kueries.get(key); + let condition = defaultCondition; + if (key === 'tags' && logicalANDForTag) { + condition = 'AND'; + } + const value = kueries.get(key)?.filter((v) => v !== ''); if (!value || value.length === 0) return ''; - return value.reduce( - (prev: string, cur: string | number, index: number, array: Array) => { - let expression: string = `${key}:${cur}`; - if (typeof cur !== 'number' && (cur.indexOf(' ') >= 0 || cur.indexOf(':') >= 0)) { - expression = `${key}:"${cur}"`; - } - if (array.length === 1) { - return expression; - } else if (array.length > 1 && index === 0) { - return `(${expression}`; - } else if (index + 1 === array.length) { - return `${prev} or ${expression})`; - } - return `${prev} or ${expression}`; - }, - '' - ); + + if (value.length === 1) { + return isAlphaNumeric(value[0] as string) ? `${key}: ${value[0]}` : `${key}: "${value[0]}"`; + } + + const values = value + .map((v) => (isAlphaNumeric(v as string) ? v : `"${v}"`)) + .join(` ${condition} `); + + return `${key}: (${values})`; }) .reduce((prev, cur, index, array) => { if (array.length === 1 || index === 0) { @@ -41,5 +44,11 @@ export const stringifyKueries = (kueries: Map>): } else if (prev === '' && !!cur) { return cur; } - return `${prev} and ${cur}`; + return `${prev} AND ${cur}`; }, ''); +}; + +const isAlphaNumeric = (str: string) => { + const format = /[ `!@#$%^&*()_+=\[\]{};':"\\|,.<>\/?~]/; + return !format.test(str); +}; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/use_snap_shot.ts b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/use_snap_shot.ts index 7fd0f0b3a410b..df402944cb462 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/use_snap_shot.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/alerts_containers/use_snap_shot.ts @@ -6,7 +6,7 @@ */ import { useFetcher } from '@kbn/observability-plugin/public'; -import { useUptimeDataView, generateUpdatedKueryString } from '../../../../hooks'; +import { useGenerateUpdatedKueryString } from '../../../../hooks'; import { fetchSnapshotCount } from '../../../../state/api'; export const useSnapShotCount = ({ query, filters }: { query: string; filters: [] | string }) => { @@ -15,9 +15,7 @@ export const useSnapShotCount = ({ query, filters }: { query: string; filters: [ ? '' : JSON.stringify(Array.from(Object.entries(filters))); - const dataView = useUptimeDataView(); - - const [esKuery, error] = generateUpdatedKueryString(dataView, query, parsedFilters); + const [esKuery, error] = useGenerateUpdatedKueryString(query, parsedFilters, undefined, true); const { data, loading } = useFetcher( () => diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/filter_group/filter_group.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/filter_group/filter_group.tsx index 16ab92a6862c2..5f10d29d4814f 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/filter_group/filter_group.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/filter_group/filter_group.tsx @@ -10,17 +10,21 @@ import { EuiFilterGroup } from '@elastic/eui'; import styled from 'styled-components'; import { capitalize } from 'lodash'; import { FieldValueSuggestions, useInspectorContext } from '@kbn/observability-plugin/public'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { useFilterUpdate } from '../../../hooks/use_filter_update'; import { useSelectedFilters } from '../../../hooks/use_selected_filters'; import { SelectedFilters } from './selected_filters'; import { useUptimeDataView } from '../../../contexts/uptime_data_view_context'; import { useGetUrlParams } from '../../../hooks'; import { EXCLUDE_RUN_ONCE_FILTER } from '../../../../../common/constants/client_defaults'; +import { useUptimeRefreshContext } from '../../../contexts/uptime_refresh_context'; const Container = styled(EuiFilterGroup)` margin-bottom: 10px; `; +export const TAG_KEY_FOR_AND_CONDITION = 'useANDForTagsFilter'; + export const FilterGroup = () => { const [updatedFieldValues, setUpdatedFieldValues] = useState<{ fieldName: string; @@ -34,6 +38,8 @@ export const FilterGroup = () => { updatedFieldValues.notValues ); + const { refreshApp } = useUptimeRefreshContext(); + const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); const { inspectorAdapters } = useInspectorContext(); @@ -42,6 +48,8 @@ export const FilterGroup = () => { const dataView = useUptimeDataView(); + const [useLogicalAND, setLogicalANDForTag] = useLocalStorage(TAG_KEY_FOR_AND_CONDITION, false); + const onFilterFieldChange = useCallback( (fieldName: string, values: string[], notValues: string[]) => { setUpdatedFieldValues({ fieldName, values, notValues }); @@ -62,9 +70,13 @@ export const FilterGroup = () => { label={label} selectedValue={selectedItems} excludedValue={excludedItems} - onChange={(values, notValues) => - onFilterFieldChange(field, values ?? [], notValues ?? []) - } + onChange={(values, notValues, isLogicalAND) => { + onFilterFieldChange(field, values ?? [], notValues ?? []); + if (isLogicalAND !== undefined) { + setLogicalANDForTag(isLogicalAND); + setTimeout(() => refreshApp(), 0); + } + }} asCombobox={false} asFilterButton={true} forceOpen={false} @@ -82,6 +94,8 @@ export const FilterGroup = () => { adapter: inspectorAdapters.requests, title: 'get' + capitalize(label) + 'FilterValues', }} + showLogicalConditionSwitch={field === 'tags'} + useLogicalAND={field === 'tags' ? useLogicalAND : undefined} /> ))} diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/query_bar/use_query_bar.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/query_bar/use_query_bar.test.tsx index 56a6fd7e6dc30..67fdf86618641 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/query_bar/use_query_bar.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/query_bar/use_query_bar.test.tsx @@ -12,7 +12,7 @@ import { MockRouter, MockKibanaProvider } from '../../../lib/helper/rtl_helpers' import { SyntaxType, useQueryBar, DEBOUNCE_INTERVAL } from './use_query_bar'; import { MountWithReduxProvider } from '../../../lib'; import * as URL from '../../../hooks/use_url_params'; -import * as ES_FILTERS from '../../../hooks/update_kuery_string'; +import * as ES_FILTERS from '../../../hooks/use_update_kuery_string'; import { UptimeUrlParams } from '../../../lib/helper/url_params'; const SAMPLE_ES_FILTERS = `{"bool":{"should":[{"match_phrase":{"monitor.id":"NodeServer"}}],"minimum_should_match":1}}`; @@ -49,7 +49,7 @@ describe.skip('useQueryBar', () => { ); useUrlParamsSpy = jest.spyOn(URL, 'useUrlParams'); useGetUrlParamsSpy = jest.spyOn(URL, 'useGetUrlParams'); - useUpdateKueryStringSpy = jest.spyOn(ES_FILTERS, 'generateUpdatedKueryString'); + useUpdateKueryStringSpy = jest.spyOn(ES_FILTERS, 'useGenerateUpdatedKueryString'); updateUrlParamsMock = jest.fn(); useUrlParamsSpy.mockImplementation(() => [jest.fn(), updateUrlParamsMock]); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/query_bar/use_query_bar.ts b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/query_bar/use_query_bar.ts index 23dd326b7e18b..63a2377c5a45f 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/query_bar/use_query_bar.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/query_bar/use_query_bar.ts @@ -10,12 +10,7 @@ import useDebounce from 'react-use/lib/useDebounce'; import { useDispatch } from 'react-redux'; import type { Query } from '@kbn/es-query'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { - useGetUrlParams, - useUptimeDataView, - generateUpdatedKueryString, - useUrlParams, -} from '../../../hooks'; +import { useGetUrlParams, useGenerateUpdatedKueryString, useUrlParams } from '../../../hooks'; import { setEsKueryString } from '../../../state/actions'; import { UptimePluginServices } from '../../../../plugin'; @@ -70,12 +65,9 @@ export const useQueryBar = (): UseQueryBarUtils => { } ); - const dataView = useUptimeDataView(); - const [, updateUrlParams] = useUrlParams(); - const [esFilters, error] = generateUpdatedKueryString( - dataView, + const [esFilters, error] = useGenerateUpdatedKueryString( query.language === SyntaxType.kuery ? (query.query as string) : undefined, paramFilters, excludedFilters diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/hooks/index.ts b/x-pack/plugins/synthetics/public/legacy_uptime/hooks/index.ts index 6d2a826caa4b4..bd9a6390e7f2e 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/hooks/index.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/hooks/index.ts @@ -6,7 +6,7 @@ */ export * from './use_composite_image'; -export * from './update_kuery_string'; +export * from './use_update_kuery_string'; export * from './use_monitor'; export * from './use_search_text'; export * from './use_cert_status'; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/hooks/update_kuery_string.ts b/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_update_kuery_string.ts similarity index 60% rename from x-pack/plugins/synthetics/public/legacy_uptime/hooks/update_kuery_string.ts rename to x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_update_kuery_string.ts index 007dfd12427f3..823c57edfd051 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/hooks/update_kuery_string.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/hooks/use_update_kuery_string.ts @@ -6,10 +6,17 @@ */ import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; -import { DataView } from '@kbn/data-views-plugin/public'; +import { useEffect, useState } from 'react'; +import { useUptimeRefreshContext } from '../contexts/uptime_refresh_context'; +import { useUptimeDataView } from '../contexts/uptime_data_view_context'; +import { TAG_KEY_FOR_AND_CONDITION } from '../components/overview/filter_group/filter_group'; import { combineFiltersAndUserSearch, stringifyKueries } from '../../../common/lib'; -const getKueryString = (urlFilters: string, excludedFilters?: string): string => { +const getKueryString = ( + urlFilters: string, + excludedFilters?: string, + logicalANDForTag?: boolean +): string => { let kueryString = ''; let excludeKueryString = ''; // We are using try/catch here because this is user entered value @@ -18,7 +25,7 @@ const getKueryString = (urlFilters: string, excludedFilters?: string): string => try { if (urlFilters !== '') { const filterMap = new Map>(JSON.parse(urlFilters)); - kueryString = stringifyKueries(filterMap); + kueryString = stringifyKueries(filterMap, logicalANDForTag); } } catch { kueryString = ''; @@ -27,7 +34,7 @@ const getKueryString = (urlFilters: string, excludedFilters?: string): string => try { if (excludedFilters) { const filterMap = new Map>(JSON.parse(excludedFilters)); - excludeKueryString = stringifyKueries(filterMap); + excludeKueryString = stringifyKueries(filterMap, logicalANDForTag); if (kueryString) { return `${kueryString} and NOT (${excludeKueryString})`; } @@ -41,13 +48,28 @@ const getKueryString = (urlFilters: string, excludedFilters?: string): string => return `NOT (${excludeKueryString})`; }; -export const generateUpdatedKueryString = ( - dataView: DataView | null, +export const useGenerateUpdatedKueryString = ( filterQueryString = '', urlFilters: string, - excludedFilters?: string + excludedFilters?: string, + disableANDFiltering?: boolean ): [string?, Error?] => { - const kueryString = getKueryString(urlFilters, excludedFilters); + const dataView = useUptimeDataView(); + + const { lastRefresh } = useUptimeRefreshContext(); + + const [kueryString, setKueryString] = useState(''); + + useEffect(() => { + if (disableANDFiltering) { + setKueryString(getKueryString(urlFilters, excludedFilters)); + } else { + // need a string comparison for local storage + const useLogicalAND = localStorage.getItem(TAG_KEY_FOR_AND_CONDITION) === 'true'; + + setKueryString(getKueryString(urlFilters, excludedFilters, useLogicalAND)); + } + }, [excludedFilters, urlFilters, lastRefresh, disableANDFiltering]); const combinedFilterString = combineFiltersAndUserSearch(filterQueryString, kueryString); From 9f2877a3719a2f758d2e06e01651fb00b1d489e1 Mon Sep 17 00:00:00 2001 From: "Quynh Nguyen (Quinn)" <43350163+qn895@users.noreply.github.com> Date: Tue, 15 Nov 2022 12:14:08 -0600 Subject: [PATCH 06/13] [APM] Replace APM correlations context popover with unified field list popover (#143416) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../field_stats/field_stats.test.tsx | 133 ++++++- .../components/field_stats/field_stats.tsx | 51 ++- .../field_stats/field_top_values.tsx | 7 +- .../field_stats/field_top_values_bucket.tsx | 48 ++- .../public/components/field_stats/index.tsx | 23 +- .../unified_field_list/public/index.ts | 12 +- .../unified_field_list/public/types.ts | 1 - .../failed_transactions_correlations/types.ts | 2 - .../common/correlations/field_stats_types.ts | 20 -- .../latency_correlations/types.ts | 2 - x-pack/plugins/apm/common/utils/term_query.ts | 20 ++ x-pack/plugins/apm/kibana.json | 5 +- .../context_popover/context_popover.tsx | 105 ------ .../context_popover/field_stats_popover.tsx | 338 ++++++++++++++++++ .../app/correlations/context_popover/index.ts | 2 +- .../context_popover/top_values.tsx | 307 ---------------- .../failed_transactions_correlations.tsx | 17 +- .../app/correlations/latency_correlations.tsx | 17 +- ..._failed_transactions_correlations.test.tsx | 56 +-- .../use_failed_transactions_correlations.ts | 17 - .../use_latency_correlations.test.tsx | 44 --- .../correlations/use_latency_correlations.ts | 15 - .../use_filters_for_mobile_charts.ts | 14 +- x-pack/plugins/apm/public/plugin.ts | 4 + .../field_stats/fetch_boolean_field_stats.ts | 74 ---- .../fetch_field_value_field_stats.ts | 58 ++- .../queries/field_stats/fetch_fields_stats.ts | 138 ------- .../field_stats/fetch_keyword_field_stats.ts | 69 ---- .../field_stats/fetch_numeric_field_stats.ts | 95 ----- .../apm/server/routes/correlations/route.ts | 76 +--- .../translations/translations/fr-FR.json | 4 - .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - .../correlations/failed_transactions.spec.ts | 19 - .../tests/correlations/latency.spec.ts | 19 - 35 files changed, 673 insertions(+), 1147 deletions(-) create mode 100644 x-pack/plugins/apm/common/utils/term_query.ts delete mode 100644 x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx create mode 100644 x-pack/plugins/apm/public/components/app/correlations/context_popover/field_stats_popover.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx delete mode 100644 x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_boolean_field_stats.ts delete mode 100644 x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_fields_stats.ts delete mode 100644 x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_keyword_field_stats.ts delete mode 100644 x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_numeric_field_stats.ts diff --git a/src/plugins/unified_field_list/public/components/field_stats/field_stats.test.tsx b/src/plugins/unified_field_list/public/components/field_stats/field_stats.test.tsx index 9e95af12f00a7..312abc2bb323f 100644 --- a/src/plugins/unified_field_list/public/components/field_stats/field_stats.test.tsx +++ b/src/plugins/unified_field_list/public/components/field_stats/field_stats.test.tsx @@ -18,8 +18,7 @@ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { DataViewField } from '@kbn/data-views-plugin/common'; import { loadFieldStats } from '../../services/field_stats'; -import FieldStats from './field_stats'; -import type { FieldStatsProps } from './field_stats'; +import FieldStats, { FieldStatsWithKbnQuery } from './field_stats'; jest.mock('../../services/field_stats', () => ({ loadFieldStats: jest.fn().mockResolvedValue({}), @@ -34,7 +33,7 @@ const mockedServices = { }; describe('UnifiedFieldList ', () => { - let defaultProps: FieldStatsProps; + let defaultProps: FieldStatsWithKbnQuery; let dataView: DataView; beforeEach(() => { @@ -202,6 +201,64 @@ describe('UnifiedFieldList ', () => { expect(loadFieldStats).toHaveBeenCalledTimes(1); }); + it('should request field stats with dsl query', async () => { + let resolveFunction: (arg: unknown) => void; + + (loadFieldStats as jest.Mock).mockImplementation(() => { + return new Promise((resolve) => { + resolveFunction = resolve; + }); + }); + + const wrapper = mountWithIntl( + f.name === 'bytes')!, + 'data-test-subj': 'testing', + }} + dslQuery={{ bool: { filter: { range: { field: 'duration', gte: 3000 } } } }} + fromDate="now-14d" + toDate="now-7d" + /> + ); + + await wrapper.update(); + + expect(loadFieldStats).toHaveBeenCalledWith({ + abortController: new AbortController(), + services: { data: mockedServices.data }, + dataView, + dslQuery: { bool: { filter: { range: { field: 'duration', gte: 3000 } } } }, + fromDate: 'now-14d', + toDate: 'now-7d', + field: defaultProps.field, + }); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + await act(async () => { + resolveFunction!({ + totalDocuments: 4633, + sampledDocuments: 4633, + sampledValues: 4633, + histogram: { + buckets: [{ count: 705, key: 0 }], + }, + topValues: { + buckets: [{ count: 147, key: 0 }], + }, + }); + }); + + await wrapper.update(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + + expect(loadFieldStats).toHaveBeenCalledTimes(1); + }); + it('should not request field stats for range fields', async () => { const wrapper = await mountWithIntl( f.name === 'ip_range')!} /> @@ -632,4 +689,74 @@ describe('UnifiedFieldList ', () => { 'Toggle either theTop valuesDistribution1273.9%1326.1%Calculated from 23 sample records.' ); }); + + it('should override the top value bar props with overrideFieldTopValueBar', async () => { + let resolveFunction: (arg: unknown) => void; + + (loadFieldStats as jest.Mock).mockImplementation(() => { + return new Promise((resolve) => { + resolveFunction = resolve; + }); + }); + + const field = dataView.fields.find((f) => f.name === 'machine.ram')!; + + const wrapper = mountWithIntl( + ({ color: 'accent' })} + /> + ); + + await wrapper.update(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + + await act(async () => { + resolveFunction!({ + totalDocuments: 100, + sampledDocuments: 23, + sampledValues: 23, + histogram: { + buckets: [ + { + count: 17, + key: 12, + }, + { + count: 6, + key: 13, + }, + ], + }, + topValues: { + buckets: [ + { + count: 17, + key: 12, + }, + { + count: 6, + key: 13, + }, + ], + }, + }); + }); + + await wrapper.update(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + + expect(loadFieldStats).toHaveBeenCalledTimes(1); + + expect(wrapper.find(EuiProgress)).toHaveLength(2); + expect(wrapper.find(EuiProgress).first().props()).toHaveProperty('color', 'accent'); + }); }); diff --git a/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx b/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx index b3600dc9f3971..f6f8063df8334 100755 --- a/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx +++ b/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx @@ -34,6 +34,7 @@ import { } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import { buildEsQuery, Query, Filter, AggregateQuery } from '@kbn/es-query'; +import { OverrideFieldTopValueBarCallback } from './field_top_values_bucket'; import type { BucketedAggregation } from '../../../common/types'; import { canProvideStatsForField } from '../../../common/utils/field_stats_utils'; import { loadFieldStats } from '../../services/field_stats'; @@ -46,7 +47,7 @@ import { } from './field_top_values'; import { FieldSummaryMessage } from './field_summary_message'; -interface State { +export interface FieldStatsState { isLoading: boolean; totalDocuments?: number; sampledDocuments?: number; @@ -63,11 +64,11 @@ export interface FieldStatsServices { charts: ChartsPluginSetup; } -export interface FieldStatsProps { +interface FieldStatsPropsBase { services: FieldStatsServices; - query: Query | AggregateQuery; - filters: Filter[]; + /** ISO formatted date string **/ fromDate: string; + /** ISO formatted date string **/ toDate: string; dataViewOrDataViewId: DataView | string; field: DataViewField; @@ -83,11 +84,30 @@ export interface FieldStatsProps { sampledDocuments?: number; }) => JSX.Element; onAddFilter?: AddFieldFilterHandler; + overrideFieldTopValueBar?: OverrideFieldTopValueBarCallback; + onStateChange?: (s: FieldStatsState) => void; +} + +export interface FieldStatsWithKbnQuery extends FieldStatsPropsBase { + /** If Kibana-supported query is provided, it will be converted to dsl query **/ + query: Query | AggregateQuery; + filters: Filter[]; + dslQuery?: never; +} + +export interface FieldStatsWithDslQuery extends FieldStatsPropsBase { + query?: never; + filters?: never; + /** If dsl query is provided, use it directly in searches **/ + dslQuery: object; } +export type FieldStatsProps = FieldStatsWithKbnQuery | FieldStatsWithDslQuery; + const FieldStatsComponent: React.FC = ({ services, query, + dslQuery, filters, fromDate, toDate, @@ -98,9 +118,11 @@ const FieldStatsComponent: React.FC = ({ overrideMissingContent, overrideFooter, onAddFilter, + overrideFieldTopValueBar, + onStateChange, }) => { const { fieldFormats, uiSettings, charts, dataViews, data } = services; - const [state, changeState] = useState({ + const [state, changeState] = useState({ isLoading: false, }); const [dataView, changeDataView] = useState(null); @@ -116,6 +138,15 @@ const FieldStatsComponent: React.FC = ({ [changeState, isCanceledRef] ); + useEffect( + function broadcastOnStateChange() { + if (onStateChange) { + onStateChange(state); + } + }, + [onStateChange, state] + ); + const setDataView: typeof changeDataView = useCallback( (nextDataView) => { if (!isCanceledRef.current) { @@ -153,7 +184,9 @@ const FieldStatsComponent: React.FC = ({ field, fromDate, toDate, - dslQuery: buildEsQuery(loadedDataView, query, filters, getEsQueryConfig(uiSettings)), + dslQuery: + dslQuery ?? + buildEsQuery(loadedDataView, query ?? [], filters, getEsQueryConfig(uiSettings)), abortController: abortControllerRef.current, }); @@ -169,7 +202,6 @@ const FieldStatsComponent: React.FC = ({ topValues: results.topValues, })); } catch (e) { - // console.error(e); setState((s) => ({ ...s, isLoading: false })); } } @@ -494,6 +526,7 @@ const FieldStatsComponent: React.FC = ({ color={color} data-test-subj={dataTestSubject} onAddFilter={onAddFilter} + overrideFieldTopValueBar={overrideFieldTopValueBar} /> ); } @@ -511,10 +544,6 @@ class ErrorBoundary extends React.Component<{}, { hasError: boolean }> { return { hasError: true }; } - // componentDidCatch(error, errorInfo) { - // console.log(error, errorInfo); - // } - render() { if (this.state.hasError) { return null; diff --git a/src/plugins/unified_field_list/public/components/field_stats/field_top_values.tsx b/src/plugins/unified_field_list/public/components/field_stats/field_top_values.tsx index adec20e38b727..1820b610fc581 100755 --- a/src/plugins/unified_field_list/public/components/field_stats/field_top_values.tsx +++ b/src/plugins/unified_field_list/public/components/field_stats/field_top_values.tsx @@ -11,7 +11,8 @@ import { euiPaletteColorBlind, EuiSpacer } from '@elastic/eui'; import { DataView, DataViewField } from '@kbn/data-plugin/common'; import type { BucketedAggregation } from '../../../common/types'; import type { AddFieldFilterHandler } from '../../types'; -import { FieldTopValuesBucket } from './field_top_values_bucket'; +import FieldTopValuesBucket from './field_top_values_bucket'; +import type { OverrideFieldTopValueBarCallback } from './field_top_values_bucket'; export interface FieldTopValuesProps { buckets: BucketedAggregation['buckets']; @@ -21,6 +22,7 @@ export interface FieldTopValuesProps { color?: string; 'data-test-subj': string; onAddFilter?: AddFieldFilterHandler; + overrideFieldTopValueBar?: OverrideFieldTopValueBarCallback; } export const FieldTopValues: React.FC = ({ @@ -31,6 +33,7 @@ export const FieldTopValues: React.FC = ({ color = getDefaultColor(), 'data-test-subj': dataTestSubject, onAddFilter, + overrideFieldTopValueBar, }) => { if (!buckets?.length) { return null; @@ -65,6 +68,7 @@ export const FieldTopValues: React.FC = ({ color={color} data-test-subj={dataTestSubject} onAddFilter={onAddFilter} + overrideFieldTopValueBar={overrideFieldTopValueBar} /> ); @@ -86,6 +90,7 @@ export const FieldTopValues: React.FC = ({ color={color} data-test-subj={dataTestSubject} onAddFilter={onAddFilter} + overrideFieldTopValueBar={overrideFieldTopValueBar} /> )} diff --git a/src/plugins/unified_field_list/public/components/field_stats/field_top_values_bucket.tsx b/src/plugins/unified_field_list/public/components/field_stats/field_top_values_bucket.tsx index e45a55d7b350e..ccae2a3dfffc1 100755 --- a/src/plugins/unified_field_list/public/components/field_stats/field_top_values_bucket.tsx +++ b/src/plugins/unified_field_list/public/components/field_stats/field_top_values_bucket.tsx @@ -21,8 +21,7 @@ import type { IFieldSubTypeMulti } from '@kbn/es-query'; import type { DataViewField } from '@kbn/data-views-plugin/common'; import type { AddFieldFilterHandler } from '../../types'; -export interface FieldTopValuesBucketProps { - type?: 'normal' | 'other'; +export interface FieldTopValuesBucketParams { field: DataViewField; fieldValue: unknown; formattedFieldValue?: string; @@ -30,22 +29,43 @@ export interface FieldTopValuesBucketProps { progressValue: number; count: number; color: string; + type?: 'normal' | 'other'; +} + +export type OverrideFieldTopValueBarCallback = ( + params: FieldTopValuesBucketParams +) => Partial; + +export interface FieldTopValuesBucketProps extends FieldTopValuesBucketParams { 'data-test-subj': string; onAddFilter?: AddFieldFilterHandler; + /** + * Optional callback to allow overriding props on bucket level + */ + overrideFieldTopValueBar?: OverrideFieldTopValueBarCallback; } -export const FieldTopValuesBucket: React.FC = ({ - type = 'normal', - field, - fieldValue, - formattedFieldValue, - formattedPercentage, - progressValue, - count, - color, +const FieldTopValuesBucket: React.FC = ({ 'data-test-subj': dataTestSubject, onAddFilter, + overrideFieldTopValueBar, + ...fieldTopValuesBucketOverridableProps }) => { + const overrides = overrideFieldTopValueBar + ? overrideFieldTopValueBar(fieldTopValuesBucketOverridableProps) + : ({} as FieldTopValuesBucketParams); + + const { + field, + type, + fieldValue, + formattedFieldValue, + formattedPercentage, + progressValue, + count, + color, + textProps = {}, + } = { ...fieldTopValuesBucketOverridableProps, ...overrides }; const fieldLabel = (field?.subType as IFieldSubTypeMulti)?.multi?.parent ?? field.name; return ( @@ -69,7 +89,7 @@ export const FieldTopValuesBucket: React.FC = ({ > {(formattedFieldValue?.length ?? 0) > 0 ? ( - + {formattedFieldValue} @@ -175,3 +195,7 @@ export const FieldTopValuesBucket: React.FC = ({
); }; + +// Necessary for React.lazy +// eslint-disable-next-line import/no-default-export +export default FieldTopValuesBucket; diff --git a/src/plugins/unified_field_list/public/components/field_stats/index.tsx b/src/plugins/unified_field_list/public/components/field_stats/index.tsx index 0cfb29d4abd5f..1eb4df243e16f 100755 --- a/src/plugins/unified_field_list/public/components/field_stats/index.tsx +++ b/src/plugins/unified_field_list/public/components/field_stats/index.tsx @@ -7,11 +7,23 @@ */ import React, { Fragment } from 'react'; -import type { FieldStatsProps, FieldStatsServices } from './field_stats'; +import type { FieldStatsProps, FieldStatsServices, FieldStatsState } from './field_stats'; +import type { + FieldTopValuesBucketProps, + FieldTopValuesBucketParams, +} from './field_top_values_bucket'; const Fallback = () => ; +const LazyFieldTopValuesBucket = React.lazy(() => import('./field_top_values_bucket')); const LazyFieldStats = React.lazy(() => import('./field_stats')); + +const WrappedFieldTopValuesBucket: React.FC = (props) => ( + }> + + +); + const WrappedFieldStats: React.FC = (props) => ( }> @@ -19,4 +31,11 @@ const WrappedFieldStats: React.FC = (props) => ( ); export const FieldStats = WrappedFieldStats; -export type { FieldStatsProps, FieldStatsServices }; +export const FieldTopValuesBucket = WrappedFieldTopValuesBucket; +export type { + FieldStatsProps, + FieldStatsServices, + FieldStatsState, + FieldTopValuesBucketProps, + FieldTopValuesBucketParams, +}; diff --git a/src/plugins/unified_field_list/public/index.ts b/src/plugins/unified_field_list/public/index.ts index 94abf51566463..d90a5092576e6 100755 --- a/src/plugins/unified_field_list/public/index.ts +++ b/src/plugins/unified_field_list/public/index.ts @@ -7,7 +7,6 @@ */ import { UnifiedFieldListPlugin } from './plugin'; - export type { FieldStatsResponse, BucketedAggregation, @@ -15,7 +14,16 @@ export type { TopValuesResult, } from '../common/types'; export { FieldListGrouped, type FieldListGroupedProps } from './components/field_list'; -export type { FieldStatsProps, FieldStatsServices } from './components/field_stats'; +export type { + FieldTopValuesBucketProps, + FieldTopValuesBucketParams, +} from './components/field_stats'; +export { FieldTopValuesBucket } from './components/field_stats'; +export type { + FieldStatsProps, + FieldStatsServices, + FieldStatsState, +} from './components/field_stats'; export { FieldStats } from './components/field_stats'; export { FieldPopover, diff --git a/src/plugins/unified_field_list/public/types.ts b/src/plugins/unified_field_list/public/types.ts index de96cf6a44cfb..d2c80286f8dea 100755 --- a/src/plugins/unified_field_list/public/types.ts +++ b/src/plugins/unified_field_list/public/types.ts @@ -7,7 +7,6 @@ */ import type { DataViewField } from '@kbn/data-views-plugin/common'; - // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface UnifiedFieldListPluginSetup {} diff --git a/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts b/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts index fdcac2b96ab80..be83646b73a3c 100644 --- a/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts +++ b/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts @@ -8,7 +8,6 @@ import { FieldValuePair, HistogramItem } from '../types'; import { CORRELATIONS_IMPACT_THRESHOLD } from './constants'; -import { FieldStats } from '../field_stats_types'; export interface FailedTransactionsCorrelation extends FieldValuePair { doc_count: number; @@ -31,6 +30,5 @@ export interface FailedTransactionsCorrelationsResponse { overallHistogram?: HistogramItem[]; totalDocCount?: number; errorHistogram?: HistogramItem[]; - fieldStats?: FieldStats[]; fallbackResult?: FailedTransactionsCorrelation; } diff --git a/x-pack/plugins/apm/common/correlations/field_stats_types.ts b/x-pack/plugins/apm/common/correlations/field_stats_types.ts index b350a0ff9e639..51a601dd39c18 100644 --- a/x-pack/plugins/apm/common/correlations/field_stats_types.ts +++ b/x-pack/plugins/apm/common/correlations/field_stats_types.ts @@ -31,24 +31,4 @@ export interface TopValuesStats { topValuesSamplerShardSize?: number; } -export interface NumericFieldStats extends TopValuesStats { - min: number; - max: number; - avg: number; - median?: number; -} - -export type KeywordFieldStats = TopValuesStats; - -export interface BooleanFieldStats { - fieldName: string; - count: number; - [key: string]: number | string; -} - -export type FieldStats = - | NumericFieldStats - | KeywordFieldStats - | BooleanFieldStats; - export type FieldValueFieldStats = TopValuesStats; diff --git a/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts b/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts index 8a612145e5d12..80067337a8b33 100644 --- a/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts +++ b/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts @@ -6,7 +6,6 @@ */ import { FieldValuePair, HistogramItem } from '../types'; -import { FieldStats } from '../field_stats_types'; export interface LatencyCorrelation extends FieldValuePair { correlation: number; @@ -21,5 +20,4 @@ export interface LatencyCorrelationsResponse { overallHistogram?: HistogramItem[]; percentileThresholdValue?: number | null; latencyCorrelations?: LatencyCorrelation[]; - fieldStats?: FieldStats[]; } diff --git a/x-pack/plugins/apm/common/utils/term_query.ts b/x-pack/plugins/apm/common/utils/term_query.ts new file mode 100644 index 0000000000000..07e20392da098 --- /dev/null +++ b/x-pack/plugins/apm/common/utils/term_query.ts @@ -0,0 +1,20 @@ +/* + * 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 type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { isEmpty, isNil } from 'lodash'; + +export function termQuery( + field: T, + value: string | boolean | number | undefined | null +): QueryDslQueryContainer[] { + if (isNil(value) || isEmpty(value)) { + return []; + } + + return [{ term: { [field]: value } }]; +} diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index ff8f2c24cfd93..3df051925ab61 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -21,6 +21,7 @@ "unifiedSearch", "dataViews", "advancedSettings", + "unifiedFieldList", "lens", "maps" ], @@ -28,8 +29,10 @@ "actions", "alerting", "cases", + "charts", "cloud", "fleet", + "fieldFormats", "home", "ml", "security", @@ -52,4 +55,4 @@ "esUiShared", "maps" ] -} \ No newline at end of file +} diff --git a/x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx b/x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx deleted file mode 100644 index 6182f4f08aa3b..0000000000000 --- a/x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx +++ /dev/null @@ -1,105 +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. - */ - -import { - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiPopoverTitle, - EuiTitle, - EuiToolTip, -} from '@elastic/eui'; -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FieldStats } from '../../../../../common/correlations/field_stats_types'; -import { OnAddFilter, TopValues } from './top_values'; -import { useTheme } from '../../../../hooks/use_theme'; - -export function CorrelationsContextPopover({ - fieldName, - fieldValue, - topValueStats, - onAddFilter, -}: { - fieldName: string; - fieldValue: string | number; - topValueStats?: FieldStats; - onAddFilter: OnAddFilter; -}) { - const [infoIsOpen, setInfoOpen] = useState(false); - const theme = useTheme(); - - if (!topValueStats) return null; - - const popoverTitle = ( - - - -
{fieldName}
-
-
-
- ); - - return ( - - ) => { - setInfoOpen(!infoIsOpen); - }} - aria-label={i18n.translate( - 'xpack.apm.correlations.fieldContextPopover.topFieldValuesAriaLabel', - { - defaultMessage: 'Show top 10 field values', - } - )} - data-test-subj={'apmCorrelationsContextPopoverButton'} - style={{ marginLeft: theme.eui.euiSizeXS }} - /> - - } - isOpen={infoIsOpen} - closePopover={() => setInfoOpen(false)} - anchorPosition="rightCenter" - data-test-subj={'apmCorrelationsContextPopover'} - > - {popoverTitle} - -
- {i18n.translate( - 'xpack.apm.correlations.fieldContextPopover.fieldTopValuesLabel', - { - defaultMessage: 'Top 10 values', - } - )} -
-
- {infoIsOpen ? ( - - ) : null} -
- ); -} diff --git a/x-pack/plugins/apm/public/components/app/correlations/context_popover/field_stats_popover.tsx b/x-pack/plugins/apm/public/components/app/correlations/context_popover/field_stats_popover.tsx new file mode 100644 index 0000000000000..6bc7ae8fecf0f --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/context_popover/field_stats_popover.tsx @@ -0,0 +1,338 @@ +/* + * 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + FieldPopover, + FieldStats, + FieldPopoverHeader, + FieldStatsServices, + FieldStatsProps, + FieldStatsState, +} from '@kbn/unified-field-list-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import { FieldTopValuesBucket } from '@kbn/unified-field-list-plugin/public'; + +import type { FieldTopValuesBucketParams } from '@kbn/unified-field-list-plugin/public'; +import { + EuiHorizontalRule, + EuiText, + EuiSpacer, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; +import numeral from '@elastic/numeral'; +import { termQuery } from '../../../../../common/utils/term_query'; +import { + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../../../common/elasticsearch_fieldnames'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; +import { useFetchParams } from '../use_fetch_params'; +import type { ApmPluginStartDeps } from '../../../../plugin'; +import { useApmDataView } from '../../../../hooks/use_apm_data_view'; +import { useTheme } from '../../../../hooks/use_theme'; +import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; + +const HIGHLIGHTED_BUCKET_PROPS = { + color: 'accent', + textProps: { + color: 'accent', + }, +}; +export function kqlQuery(kql: string): estypes.QueryDslQueryContainer[] { + return !!kql ? [toElasticsearchQuery(fromKueryExpression(kql))] : []; +} + +export type OnAddFilter = ({ + fieldName, + fieldValue, + include, +}: { + fieldName: string; + fieldValue: string | number; + include: boolean; +}) => void; + +type FieldStatsPopoverContentProps = Omit< + FieldStatsProps, + 'dataViewOrDataViewId' +> & { + fieldName: string; + fieldValue: string | number; + dslQuery: object; + dataView: DataView; +}; + +export function FieldStatsPopoverContent({ + fieldName, + fieldValue, + services, + field, + dataView, + dslQuery, + filters, + fromDate, + toDate, + onAddFilter, + overrideFieldTopValueBar, +}: FieldStatsPopoverContentProps) { + const [needToFetchIndividualStat, setNeedToFetchIndividualStat] = + useState(false); + + const onStateChange = useCallback( + (nextState: FieldStatsState) => { + const { topValues } = nextState; + const idxToHighlight = Array.isArray(topValues) + ? topValues.findIndex((value) => value.key === fieldValue) + : null; + + setNeedToFetchIndividualStat( + idxToHighlight === -1 && + fieldName !== undefined && + fieldValue !== undefined + ); + }, + [fieldName, fieldValue] + ); + + const params = useFetchParams(); + const { data: fieldValueStats, status } = useFetcher( + (callApmApi) => { + if (needToFetchIndividualStat) { + return callApmApi( + 'GET /internal/apm/correlations/field_value_stats/transactions', + { + params: { + query: { + ...params, + fieldName, + fieldValue, + // Using sampler shard size to match with unified field list's default + samplerShardSize: '5000', + }, + }, + } + ); + } + }, + [params, fieldName, fieldValue, needToFetchIndividualStat] + ); + const progressBarMax = fieldValueStats?.topValuesSampleSize; + const formatter = dataView.getFormatterForField(field); + + return ( + <> + + {needToFetchIndividualStat ? ( + <> + + + + + + {status === FETCH_STATUS.SUCCESS && + Array.isArray(fieldValueStats?.topValues) ? ( + fieldValueStats?.topValues.map((value) => { + if (progressBarMax === undefined) return null; + + const formatted = formatter.convert(fieldValue); + const decimal = value.doc_count / progressBarMax; + const valueText = + progressBarMax !== undefined + ? numeral(decimal).format('0.0%') + : ''; + + return ( + + ); + }) + ) : ( + + + + )} + + ) : null} + + ); +} +export function FieldStatsPopover({ + fieldName, + fieldValue, + onAddFilter, +}: { + fieldName: string; + fieldValue: string | number; + onAddFilter: OnAddFilter; +}) { + const { + query: { kuery: kql }, + } = useApmParams('/services/{serviceName}'); + + const { start, end } = useFetchParams(); + + const { + data, + core: { uiSettings }, + } = useApmPluginContext(); + const { dataView } = useApmDataView(); + const { + services: { fieldFormats, charts }, + } = useKibana(); + + const [infoIsOpen, setInfoOpen] = useState(false); + const field = dataView?.getFieldByName(fieldName); + + const closePopover = useCallback(() => setInfoOpen(false), []); + const theme = useTheme(); + + const params = useFetchParams(); + + const fieldStatsQuery = useMemo(() => { + const dslQuery = kqlQuery(kql); + return { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, params.serviceName), + ...termQuery(TRANSACTION_TYPE, params.transactionType), + ...termQuery(TRANSACTION_NAME, params.transactionName), + ...dslQuery, + ], + }, + }; + }, [params, kql]); + + const fieldStatsServices: Partial = useMemo( + () => ({ + uiSettings, + dataViews: data.dataViews, + data, + fieldFormats, + charts, + }), + [uiSettings, data, fieldFormats, charts] + ); + + const addFilter = useCallback( + ( + popoverField: DataViewField | '_exists_', + value: unknown, + type: '+' | '-' + ) => { + if ( + popoverField !== '_exists_' && + (typeof value === 'number' || typeof value === 'string') + ) { + onAddFilter({ + fieldName: popoverField.name, + fieldValue: value, + include: type === '+', + }); + } + }, + [onAddFilter] + ); + + const overrideFieldTopValueBar = useCallback( + (fieldTopValuesBucketParams: FieldTopValuesBucketParams) => { + if (fieldTopValuesBucketParams.type === 'other') { + return { color: 'primary' }; + } + return fieldValue === fieldTopValuesBucketParams.fieldValue + ? HIGHLIGHTED_BUCKET_PROPS + : {}; + }, + [fieldValue] + ); + + if (!fieldFormats || !charts || !field || !dataView) return null; + + const trigger = ( + + ) => { + setInfoOpen(!infoIsOpen); + }} + aria-label={i18n.translate( + 'xpack.apm.correlations.fieldContextPopover.topFieldValuesAriaLabel', + { + defaultMessage: 'Show top 10 field values', + } + )} + data-test-subj={'apmCorrelationsContextPopoverButton'} + style={{ marginLeft: theme.eui.euiSizeXS }} + /> + + ); + + return ( + ( + + )} + renderContent={() => ( + <> + + + )} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/correlations/context_popover/index.ts b/x-pack/plugins/apm/public/components/app/correlations/context_popover/index.ts index 5588328da4452..c982a7dd397ea 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/context_popover/index.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/context_popover/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { CorrelationsContextPopover } from './context_popover'; +export { FieldStatsPopover } from './field_stats_popover'; diff --git a/x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx b/x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx deleted file mode 100644 index aa5547fc03967..0000000000000 --- a/x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx +++ /dev/null @@ -1,307 +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. - */ -import React from 'react'; -import { - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiProgress, - EuiSpacer, - EuiToolTip, - EuiText, - EuiHorizontalRule, - EuiLoadingSpinner, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import numeral from '@elastic/numeral'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { - FieldStats, - TopValueBucket, -} from '../../../../../common/correlations/field_stats_types'; -import { asPercent } from '../../../../../common/utils/formatters'; -import { useTheme } from '../../../../hooks/use_theme'; -import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; -import { useFetchParams } from '../use_fetch_params'; - -export type OnAddFilter = ({ - fieldName, - fieldValue, - include, -}: { - fieldName: string; - fieldValue: string | number; - include: boolean; -}) => void; - -interface TopValueProps { - progressBarMax: number; - barColor: string; - value: TopValueBucket; - isHighlighted: boolean; - fieldName: string; - onAddFilter?: OnAddFilter; - valueText?: string; - reverseLabel?: boolean; -} -export function TopValue({ - progressBarMax, - barColor, - value, - isHighlighted, - fieldName, - onAddFilter, - valueText, - reverseLabel = false, -}: TopValueProps) { - const theme = useTheme(); - return ( - - - - {value.key} - - } - className="eui-textTruncate" - aria-label={value.key.toString()} - valueText={valueText} - labelProps={ - isHighlighted - ? { - style: { fontWeight: 'bold' }, - } - : undefined - } - /> - - {fieldName !== undefined && - value.key !== undefined && - onAddFilter !== undefined ? ( - <> - { - onAddFilter({ - fieldName, - fieldValue: - typeof value.key === 'number' - ? value.key.toString() - : value.key, - include: true, - }); - }} - aria-label={i18n.translate( - 'xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel', - { - defaultMessage: 'Filter for {fieldName}: "{value}"', - values: { fieldName, value: value.key }, - } - )} - data-test-subj={`apmFieldContextTopValuesAddFilterButton-${value.key}-${value.key}`} - style={{ - minHeight: 'auto', - width: theme.eui.euiSizeL, - paddingRight: 2, - paddingLeft: 2, - paddingTop: 0, - paddingBottom: 0, - }} - /> - { - onAddFilter({ - fieldName, - fieldValue: - typeof value.key === 'number' - ? value.key.toString() - : value.key, - include: false, - }); - }} - aria-label={i18n.translate( - 'xpack.apm.correlations.fieldContextPopover.removeFilterAriaLabel', - { - defaultMessage: 'Filter out {fieldName}: "{value}"', - values: { fieldName, value: value.key }, - } - )} - data-test-subj={`apmFieldContextTopValuesExcludeFilterButton-${value.key}-${value.key}`} - style={{ - minHeight: 'auto', - width: theme.eui.euiSizeL, - paddingTop: 0, - paddingBottom: 0, - paddingRight: 2, - paddingLeft: 2, - }} - /> - - ) : null} - - ); -} - -interface TopValuesProps { - topValueStats: FieldStats; - compressed?: boolean; - onAddFilter?: OnAddFilter; - fieldValue?: string | number; -} - -export function TopValues({ - topValueStats, - onAddFilter, - fieldValue, -}: TopValuesProps) { - const { topValues, topValuesSampleSize, count, fieldName } = topValueStats; - const theme = useTheme(); - - const idxToHighlight = Array.isArray(topValues) - ? topValues.findIndex((value) => value.key === fieldValue) - : null; - - const params = useFetchParams(); - const { data: fieldValueStats, status } = useFetcher( - (callApmApi) => { - if ( - idxToHighlight === -1 && - fieldName !== undefined && - fieldValue !== undefined - ) { - return callApmApi( - 'GET /internal/apm/correlations/field_value_stats/transactions', - { - params: { - query: { - ...params, - fieldName, - fieldValue, - }, - }, - } - ); - } - }, - [params, fieldName, fieldValue, idxToHighlight] - ); - if ( - !Array.isArray(topValues) || - topValues?.length === 0 || - fieldValue === undefined - ) - return null; - - const sampledSize = - typeof topValuesSampleSize === 'string' - ? parseInt(topValuesSampleSize, 10) - : topValuesSampleSize; - - const progressBarMax = sampledSize ?? count; - return ( -
- {Array.isArray(topValues) && - topValues.map((value) => { - const isHighlighted = - fieldValue !== undefined && value.key === fieldValue; - const barColor = isHighlighted ? 'accent' : 'primary'; - const valueText = - progressBarMax !== undefined - ? numeral(value.doc_count / progressBarMax).format('0.0%') // asPercent(value.doc_count, progressBarMax) - : undefined; - - return ( - <> - - - - ); - })} - - {idxToHighlight === -1 && ( - <> - - - - - - {status === FETCH_STATUS.SUCCESS && - Array.isArray(fieldValueStats?.topValues) ? ( - fieldValueStats?.topValues.map((value) => { - const valueText = - progressBarMax !== undefined - ? asPercent(value.doc_count, progressBarMax) - : undefined; - - return ( - - ); - }) - ) : ( - - - - )} - - )} - - {topValueStats.topValuesSampleSize !== undefined && ( - <> - - - {i18n.translate( - 'xpack.apm.correlations.fieldContextPopover.calculatedFromSampleDescription', - { - defaultMessage: - 'Calculated from sample of {sampleSize} documents', - values: { sampleSize: topValueStats.topValuesSampleSize }, - } - )} - - - )} -
- ); -} diff --git a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx index 8ca7c096d994e..af9dccbc4c672 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx @@ -29,12 +29,12 @@ import { i18n } from '@kbn/i18n'; import { useUiTracker } from '@kbn/observability-plugin/public'; import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { FieldStatsPopover } from './context_popover/field_stats_popover'; import { asPercent, asPreciseDecimal, } from '../../../../common/utils/formatters'; import { FailedTransactionsCorrelation } from '../../../../common/correlations/failed_transactions_correlations/types'; -import { FieldStats } from '../../../../common/correlations/field_stats_types'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { useLocalStorage } from '../../../hooks/use_local_storage'; @@ -50,8 +50,7 @@ import { DurationDistributionChart } from '../../shared/charts/duration_distribu import { CorrelationsEmptyStatePrompt } from './empty_state_prompt'; import { CrossClusterSearchCompatibilityWarning } from './cross_cluster_search_warning'; import { CorrelationsProgressControls } from './progress_controls'; -import { CorrelationsContextPopover } from './context_popover'; -import { OnAddFilter } from './context_popover/top_values'; +import { OnAddFilter } from './context_popover/field_stats_popover'; import { useFailedTransactionsCorrelations } from './use_failed_transactions_correlations'; import { getTransactionDistributionChartData } from './get_transaction_distribution_chart_data'; @@ -74,13 +73,6 @@ export function FailedTransactionsCorrelations({ const { progress, response, startFetch, cancelFetch } = useFailedTransactionsCorrelations(); - const fieldStats: Record | undefined = useMemo(() => { - return response.fieldStats?.reduce((obj, field) => { - obj[field.fieldName] = field; - return obj; - }, {} as Record); - }, [response?.fieldStats]); - const { overallHistogram, hasData, status } = getOverallHistogram( response, progress.isRunning @@ -295,10 +287,9 @@ export function FailedTransactionsCorrelations({ render: (_, { fieldName, fieldValue }) => ( <> {fieldName} - @@ -363,7 +354,7 @@ export function FailedTransactionsCorrelations({ ], }, ] as Array>; - }, [fieldStats, onAddFilter, showStats]); + }, [onAddFilter, showStats]); useEffect(() => { if (progress.error) { diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx index 35941d960d62e..0a53cb0f3b40f 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx @@ -28,9 +28,9 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useUiTracker } from '@kbn/observability-plugin/public'; import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { FieldStatsPopover } from './context_popover/field_stats_popover'; import { asPreciseDecimal } from '../../../../common/utils/formatters'; import { LatencyCorrelation } from '../../../../common/correlations/latency_correlations/types'; -import { FieldStats } from '../../../../common/correlations/field_stats_types'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; @@ -45,8 +45,7 @@ import { getOverallHistogram } from './utils/get_overall_histogram'; import { CorrelationsEmptyStatePrompt } from './empty_state_prompt'; import { CrossClusterSearchCompatibilityWarning } from './cross_cluster_search_warning'; import { CorrelationsProgressControls } from './progress_controls'; -import { CorrelationsContextPopover } from './context_popover'; -import { OnAddFilter } from './context_popover/top_values'; +import { OnAddFilter } from './context_popover/field_stats_popover'; import { useLatencyCorrelations } from './use_latency_correlations'; import { getTransactionDistributionChartData } from './get_transaction_distribution_chart_data'; import { useTheme } from '../../../hooks/use_theme'; @@ -79,13 +78,6 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { progress.isRunning ); - const fieldStats: Record | undefined = useMemo(() => { - return response.fieldStats?.reduce((obj, field) => { - obj[field.fieldName] = field; - return obj; - }, {} as Record); - }, [response?.fieldStats]); - useEffect(() => { if (progress.error) { notifications.toasts.addDanger({ @@ -201,10 +193,9 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { render: (_, { fieldName, fieldValue }) => ( <> {fieldName} - @@ -266,7 +257,7 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { ), }, ], - [fieldStats, onAddFilter] + [onAddFilter] ); const [sortField, setSortField] = diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.test.tsx b/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.test.tsx index 00c768fc1abbf..0a71f4c28e5a1 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.test.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.test.tsx @@ -68,13 +68,6 @@ function wrapper({ }, ], }; - case 'POST /internal/apm/correlations/field_stats/transactions': - return { - stats: [ - { fieldName: 'field-name-1', count: 123 }, - { fieldName: 'field-name-2', count: 1111 }, - ], - }; default: return {}; } @@ -168,7 +161,7 @@ describe('useFailedTransactionsCorrelations', () => { ); try { - // Each simulated request takes 100ms. After an inital 50ms + // Each simulated request takes 100ms. After an initial 50ms // we track the internal requests the hook is running and // check the expected progress after these requests. jest.advanceTimersByTime(50); @@ -183,7 +176,6 @@ describe('useFailedTransactionsCorrelations', () => { }); expect(result.current.response).toEqual({ ccsWarning: false, - fieldStats: undefined, errorHistogram: undefined, failedTransactionsCorrelations: undefined, overallHistogram: [ @@ -205,7 +197,6 @@ describe('useFailedTransactionsCorrelations', () => { }); expect(result.current.response).toEqual({ ccsWarning: false, - fieldStats: undefined, errorHistogram: [ { doc_count: 1234, @@ -233,47 +224,6 @@ describe('useFailedTransactionsCorrelations', () => { loaded: 0.15, }); - jest.advanceTimersByTime(100); - await waitFor(() => expect(result.current.progress.loaded).toBe(0.9)); - - expect(result.current.progress).toEqual({ - error: undefined, - isRunning: true, - loaded: 0.9, - }); - - expect(result.current.response).toEqual({ - ccsWarning: false, - fieldStats: undefined, - errorHistogram: [ - { - doc_count: 1234, - key: 'the-key', - }, - ], - failedTransactionsCorrelations: [ - { - fieldName: 'field-name-1', - fieldValue: 'field-value-1', - doc_count: 123, - bg_count: 1234, - score: 0.66, - pValue: 0.01, - normalizedScore: 0.85, - failurePercentage: 30, - successPercentage: 70, - histogram: [{ key: 'the-key', doc_count: 123 }], - }, - ], - overallHistogram: [ - { - doc_count: 1234, - key: 'the-key', - }, - ], - percentileThresholdValue: 1.234, - }); - jest.advanceTimersByTime(100); await waitFor(() => expect(result.current.progress.loaded).toBe(1)); @@ -285,10 +235,6 @@ describe('useFailedTransactionsCorrelations', () => { expect(result.current.response).toEqual({ ccsWarning: false, - fieldStats: [ - { fieldName: 'field-name-1', count: 123 }, - { fieldName: 'field-name-2', count: 1111 }, - ], errorHistogram: [ { doc_count: 1234, diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts b/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts index f9c365c539e0f..59c594c04b0c5 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts @@ -73,7 +73,6 @@ export function useFailedTransactionsCorrelations() { overallHistogram: undefined, totalDocCount: undefined, errorHistogram: undefined, - fieldStats: undefined, }); setResponse.flush(); @@ -249,22 +248,6 @@ export function useFailedTransactionsCorrelations() { } } - setResponse.flush(); - - const { stats } = await callApmApi( - 'POST /internal/apm/correlations/field_stats/transactions', - { - signal: abortCtrl.current.signal, - params: { - body: { - ...fetchParams, - fieldsToSample: [...fieldsToSample], - }, - }, - } - ); - - responseUpdate.fieldStats = stats; setResponse({ ...responseUpdate, fallbackResult, diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.test.tsx b/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.test.tsx index a09530960589c..57b67da649ceb 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.test.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.test.tsx @@ -62,13 +62,6 @@ function wrapper({ }, ], }; - case 'POST /internal/apm/correlations/field_stats/transactions': - return { - stats: [ - { fieldName: 'field-name-1', count: 123 }, - { fieldName: 'field-name-2', count: 1111 }, - ], - }; default: return {}; } @@ -164,7 +157,6 @@ describe('useLatencyCorrelations', () => { }); expect(result.current.response).toEqual({ ccsWarning: false, - fieldStats: undefined, latencyCorrelations: undefined, overallHistogram: [ { @@ -200,38 +192,6 @@ describe('useLatencyCorrelations', () => { jest.advanceTimersByTime(100); await waitFor(() => expect(result.current.progress.loaded).toBe(1)); - expect(result.current.progress).toEqual({ - error: undefined, - isRunning: true, - loaded: 1, - }); - - expect(result.current.response).toEqual({ - ccsWarning: false, - fieldStats: undefined, - latencyCorrelations: [ - { - fieldName: 'field-name-1', - fieldValue: 'field-value-1', - correlation: 0.5, - histogram: [{ key: 'the-key', doc_count: 123 }], - ksTest: 0.001, - }, - ], - overallHistogram: [ - { - doc_count: 1234, - key: 'the-key', - }, - ], - percentileThresholdValue: 1.234, - }); - - jest.advanceTimersByTime(100); - await waitFor(() => - expect(result.current.response.fieldStats).toBeDefined() - ); - expect(result.current.progress).toEqual({ error: undefined, isRunning: false, @@ -240,10 +200,6 @@ describe('useLatencyCorrelations', () => { expect(result.current.response).toEqual({ ccsWarning: false, - fieldStats: [ - { fieldName: 'field-name-1', count: 123 }, - { fieldName: 'field-name-2', count: 1111 }, - ], latencyCorrelations: [ { fieldName: 'field-name-1', diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts b/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts index b2ba6fc4c68b1..78013f421a87c 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts @@ -73,7 +73,6 @@ export function useLatencyCorrelations() { percentileThresholdValue: undefined, overallHistogram: undefined, totalDocCount: undefined, - fieldStats: undefined, }); setResponse.flush(); @@ -257,20 +256,6 @@ export function useLatencyCorrelations() { } setResponse.flush(); - const { stats } = await callApmApi( - 'POST /internal/apm/correlations/field_stats/transactions', - { - signal: abortCtrl.current.signal, - params: { - body: { - ...fetchParams, - fieldsToSample: [...fieldsToSample], - }, - }, - } - ); - - responseUpdate.fieldStats = stats; setResponse({ ...responseUpdate, loaded: LOADED_DONE, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/use_filters_for_mobile_charts.ts b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/use_filters_for_mobile_charts.ts index 3412decc3f9be..b64bbb1c0cc5b 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/use_filters_for_mobile_charts.ts +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_charts/use_filters_for_mobile_charts.ts @@ -6,8 +6,7 @@ */ import { useMemo } from 'react'; -import { type QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; -import { isNil, isEmpty } from 'lodash'; +import { termQuery } from '../../../../../common/utils/term_query'; import { environmentQuery } from '../../../../../common/utils/environment_query'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { @@ -19,17 +18,6 @@ import { SERVICE_VERSION, } from '../../../../../common/elasticsearch_fieldnames'; -function termQuery( - field: T, - value: string | boolean | number | undefined | null -): QueryDslQueryContainer[] { - if (isNil(value) || isEmpty(value)) { - return []; - } - - return [{ term: { [field]: value } }]; -} - export function useFiltersForMobileCharts() { const { path: { serviceName }, diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 0ea3fa56d1dda..57124ba6b1fe3 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -51,6 +51,8 @@ import type { SecurityPluginStart } from '@kbn/security-plugin/public'; import { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import { InfraClientStartExports } from '@kbn/infra-plugin/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { ChartsPluginStart } from '@kbn/charts-plugin/public'; +import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { registerApmRuleTypes } from './components/alerting/rule_types/register_apm_rule_types'; import { getApmEnrollmentFlyoutData, @@ -82,6 +84,7 @@ export interface ApmPluginSetupDeps { export interface ApmPluginStartDeps { alerting?: AlertingPluginPublicStart; + charts?: ChartsPluginStart; data: DataPublicPluginStart; embeddable: EmbeddableStart; home: void; @@ -92,6 +95,7 @@ export interface ApmPluginStartDeps { triggersActionsUi: TriggersAndActionsUIPublicPluginStart; observability: ObservabilityPublicStart; fleet?: FleetStart; + fieldFormats?: FieldFormatsStart; security?: SecurityPluginStart; spaces?: SpacesPluginStart; infra?: InfraClientStartExports; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_boolean_field_stats.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_boolean_field_stats.ts deleted file mode 100644 index ff1019778ad56..0000000000000 --- a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_boolean_field_stats.ts +++ /dev/null @@ -1,74 +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. - */ - -import { ProcessorEvent } from '@kbn/observability-plugin/common'; -import { - CommonCorrelationsQueryParams, - FieldValuePair, -} from '../../../../../common/correlations/types'; -import { BooleanFieldStats } from '../../../../../common/correlations/field_stats_types'; -import { getCommonCorrelationsQuery } from '../get_common_correlations_query'; -import { APMEventClient } from '../../../../lib/helpers/create_es_client/create_apm_event_client'; - -export const fetchBooleanFieldStats = async ({ - apmEventClient, - eventType, - start, - end, - environment, - kuery, - field, - query, -}: CommonCorrelationsQueryParams & { - apmEventClient: APMEventClient; - eventType: ProcessorEvent; - field: FieldValuePair; -}): Promise => { - const { fieldName } = field; - - const { aggregations } = await apmEventClient.search( - 'get_boolean_field_stats', - { - apm: { - events: [eventType], - }, - body: { - size: 0, - track_total_hits: false, - query: getCommonCorrelationsQuery({ - start, - end, - environment, - kuery, - query, - }), - aggs: { - sampled_value_count: { - filter: { exists: { field: fieldName } }, - }, - sampled_values: { - terms: { - field: fieldName, - size: 2, - }, - }, - }, - }, - } - ); - - const stats: BooleanFieldStats = { - fieldName: field.fieldName, - count: aggregations?.sampled_value_count.doc_count ?? 0, - }; - - const valueBuckets = aggregations?.sampled_values?.buckets ?? []; - valueBuckets.forEach((bucket) => { - stats[`${bucket.key.toString()}Count`] = bucket.doc_count; - }); - return stats; -}; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_field_value_field_stats.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_field_value_field_stats.ts index 622c7b7d8952e..c8d555f8ba25d 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_field_value_field_stats.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_field_value_field_stats.ts @@ -6,6 +6,11 @@ */ import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { + AggregationsAggregationContainer, + AggregationsSamplerAggregate, + AggregationsSingleBucketAggregateBase, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { CommonCorrelationsQueryParams, FieldValuePair, @@ -26,11 +31,43 @@ export const fetchFieldValueFieldStats = async ({ kuery, query, field, + samplerShardSize, }: CommonCorrelationsQueryParams & { eventType: ProcessorEvent; apmEventClient: APMEventClient; field: FieldValuePair; + samplerShardSize?: number; }): Promise => { + const shouldSample = samplerShardSize !== undefined && samplerShardSize > 0; + + let aggs: Record = { + filtered_count: { + filter: { + term: { + [`${field?.fieldName}`]: field?.fieldValue, + }, + }, + }, + }; + + if (shouldSample) { + aggs = { + sample: { + sampler: { + shard_size: samplerShardSize, + }, + aggs: { + filtered_count: { + filter: { + term: { + [`${field?.fieldName}`]: field?.fieldValue, + }, + }, + }, + }, + }, + }; + } const { aggregations } = await apmEventClient.search( 'get_field_value_field_stats', { @@ -47,30 +84,29 @@ export const fetchFieldValueFieldStats = async ({ kuery, query, }), - aggs: { - filtered_count: { - filter: { - term: { - [`${field?.fieldName}`]: field?.fieldValue, - }, - }, - }, - }, + aggs, }, } ); + const results = ( + shouldSample + ? (aggregations?.sample as AggregationsSamplerAggregate)?.filtered_count + : aggregations?.filtered_count + ) as AggregationsSingleBucketAggregateBase; + const topValues: TopValueBucket[] = [ { key: field.fieldValue, - doc_count: aggregations?.filtered_count.doc_count ?? 0, + doc_count: (results.doc_count as number) ?? 0, }, ]; const stats = { fieldName: field.fieldName, topValues, - topValuesSampleSize: aggregations?.filtered_count.doc_count ?? 0, + topValuesSampleSize: + (aggregations?.sample as AggregationsSamplerAggregate)?.doc_count ?? 0, }; return stats; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_fields_stats.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_fields_stats.ts deleted file mode 100644 index 90ed9b3a92950..0000000000000 --- a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_fields_stats.ts +++ /dev/null @@ -1,138 +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. - */ -import { chunk } from 'lodash'; -import { ES_FIELD_TYPES } from '@kbn/field-types'; -import { rangeQuery } from '@kbn/observability-plugin/server'; -import { ProcessorEvent } from '@kbn/observability-plugin/common'; -import { - CommonCorrelationsQueryParams, - FieldValuePair, -} from '../../../../../common/correlations/types'; -import { FieldStats } from '../../../../../common/correlations/field_stats_types'; -import { fetchKeywordFieldStats } from './fetch_keyword_field_stats'; -import { fetchNumericFieldStats } from './fetch_numeric_field_stats'; -import { fetchBooleanFieldStats } from './fetch_boolean_field_stats'; -import { APMEventClient } from '../../../../lib/helpers/create_es_client/create_apm_event_client'; - -export const fetchFieldsStats = async ({ - apmEventClient, - eventType, - start, - end, - environment, - kuery, - query, - fieldsToSample, -}: CommonCorrelationsQueryParams & { - eventType: ProcessorEvent; - apmEventClient: APMEventClient; - fieldsToSample: string[]; -}): Promise<{ - stats: FieldStats[]; - errors: any[]; -}> => { - const stats: FieldStats[] = []; - const errors: any[] = []; - - if (fieldsToSample.length === 0) return { stats, errors }; - - const respMapping = await apmEventClient.fieldCaps( - 'get_field_caps_for_field_stats', - { - apm: { - events: [eventType], - }, - body: { - index_filter: { - bool: { - filter: [...rangeQuery(start, end)], - }, - }, - }, - fields: fieldsToSample, - } - ); - - const fieldStatsPromises = Object.entries(respMapping.fields) - .map(([key, value], idx) => { - const field: FieldValuePair = { fieldName: key, fieldValue: '' }; - const fieldTypes = Object.keys(value); - - for (const ft of fieldTypes) { - switch (ft) { - case ES_FIELD_TYPES.KEYWORD: - case ES_FIELD_TYPES.IP: - return fetchKeywordFieldStats({ - apmEventClient, - eventType, - start, - end, - environment, - kuery, - query, - field, - }); - break; - - case 'numeric': - case 'number': - case ES_FIELD_TYPES.FLOAT: - case ES_FIELD_TYPES.HALF_FLOAT: - case ES_FIELD_TYPES.SCALED_FLOAT: - case ES_FIELD_TYPES.DOUBLE: - case ES_FIELD_TYPES.INTEGER: - case ES_FIELD_TYPES.LONG: - case ES_FIELD_TYPES.SHORT: - case ES_FIELD_TYPES.UNSIGNED_LONG: - case ES_FIELD_TYPES.BYTE: - return fetchNumericFieldStats({ - apmEventClient, - eventType, - start, - end, - environment, - kuery, - query, - field, - }); - - break; - case ES_FIELD_TYPES.BOOLEAN: - return fetchBooleanFieldStats({ - apmEventClient, - eventType, - start, - end, - environment, - kuery, - query, - field, - }); - - default: - return; - } - } - }) - .filter((f) => f !== undefined) as Array>; - - const batches = chunk(fieldStatsPromises, 10); - for (let i = 0; i < batches.length; i++) { - try { - const results = await Promise.allSettled(batches[i]); - results.forEach((r) => { - if (r.status === 'fulfilled' && r.value !== undefined) { - stats.push(r.value); - } - }); - } catch (e) { - errors.push(e); - } - } - - return { stats, errors }; -}; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_keyword_field_stats.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_keyword_field_stats.ts deleted file mode 100644 index 7127db07721e7..0000000000000 --- a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_keyword_field_stats.ts +++ /dev/null @@ -1,69 +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. - */ - -import { ProcessorEvent } from '@kbn/observability-plugin/common'; -import { - CommonCorrelationsQueryParams, - FieldValuePair, -} from '../../../../../common/correlations/types'; -import { KeywordFieldStats } from '../../../../../common/correlations/field_stats_types'; -import { getCommonCorrelationsQuery } from '../get_common_correlations_query'; -import { APMEventClient } from '../../../../lib/helpers/create_es_client/create_apm_event_client'; - -export const fetchKeywordFieldStats = async ({ - apmEventClient, - eventType, - start, - end, - environment, - kuery, - query, - field, -}: CommonCorrelationsQueryParams & { - apmEventClient: APMEventClient; - eventType: ProcessorEvent; - field: FieldValuePair; -}): Promise => { - const body = await apmEventClient.search('get_keyword_field_stats', { - apm: { - events: [eventType], - }, - body: { - size: 0, - track_total_hits: false, - query: getCommonCorrelationsQuery({ - start, - end, - kuery, - environment, - query, - }), - aggs: { - sampled_top: { - terms: { - field: field.fieldName, - size: 10, - }, - }, - }, - }, - }); - - const aggregations = body.aggregations; - const topValues = aggregations?.sampled_top?.buckets ?? []; - - const stats = { - fieldName: field.fieldName, - topValues, - topValuesSampleSize: topValues.reduce( - (acc, curr) => acc + curr.doc_count, - aggregations?.sampled_top?.sum_other_doc_count ?? 0 - ), - }; - - return stats; -}; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_numeric_field_stats.ts b/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_numeric_field_stats.ts deleted file mode 100644 index 63bd2a3ea9c8b..0000000000000 --- a/x-pack/plugins/apm/server/routes/correlations/queries/field_stats/fetch_numeric_field_stats.ts +++ /dev/null @@ -1,95 +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. - */ - -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { ProcessorEvent } from '@kbn/observability-plugin/common'; -import { - NumericFieldStats, - TopValueBucket, -} from '../../../../../common/correlations/field_stats_types'; -import { - CommonCorrelationsQueryParams, - FieldValuePair, -} from '../../../../../common/correlations/types'; -import { APMEventClient } from '../../../../lib/helpers/create_es_client/create_apm_event_client'; -import { getCommonCorrelationsQuery } from '../get_common_correlations_query'; - -export const fetchNumericFieldStats = async ({ - apmEventClient, - eventType, - start, - end, - environment, - kuery, - query, - field, -}: CommonCorrelationsQueryParams & { - apmEventClient: APMEventClient; - eventType: ProcessorEvent; - field: FieldValuePair; -}): Promise => { - const { fieldName } = field; - - const { aggregations } = await apmEventClient.search( - 'get_numeric_field_stats', - { - apm: { - events: [eventType], - }, - body: { - size: 0, - track_total_hits: false, - query: getCommonCorrelationsQuery({ - start, - end, - environment, - kuery, - query, - }), - aggs: { - sampled_field_stats: { - filter: { exists: { field: fieldName } }, - aggs: { - actual_stats: { - stats: { field: fieldName }, - }, - }, - }, - sampled_top: { - terms: { - field: fieldName, - size: 10, - order: { - _count: 'desc', - }, - }, - }, - }, - }, - } - ); - - const docCount = aggregations?.sampled_field_stats?.doc_count ?? 0; - const fieldStatsResp: Partial = - aggregations?.sampled_field_stats?.actual_stats ?? {}; - const topValues = aggregations?.sampled_top?.buckets ?? []; - - const stats: NumericFieldStats = { - fieldName: field.fieldName, - count: docCount, - min: fieldStatsResp?.min || 0, - max: fieldStatsResp?.max || 0, - avg: fieldStatsResp?.avg || 0, - topValues, - topValuesSampleSize: topValues.reduce( - (acc: number, curr: TopValueBucket) => acc + curr.doc_count, - aggregations?.sampled_top?.sum_other_doc_count ?? 0 - ), - }; - - return stats; -}; diff --git a/x-pack/plugins/apm/server/routes/correlations/route.ts b/x-pack/plugins/apm/server/routes/correlations/route.ts index cdadd7053f517..00cba18950319 100644 --- a/x-pack/plugins/apm/server/routes/correlations/route.ts +++ b/x-pack/plugins/apm/server/routes/correlations/route.ts @@ -26,7 +26,6 @@ import { import { fetchFieldValueFieldStats } from './queries/field_stats/fetch_field_value_field_stats'; import { fetchFieldValuePairs } from './queries/fetch_field_value_pairs'; import { fetchSignificantCorrelations } from './queries/fetch_significant_correlations'; -import { fetchFieldsStats } from './queries/field_stats/fetch_fields_stats'; import { fetchPValues } from './queries/fetch_p_values'; import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; @@ -91,74 +90,6 @@ const fieldCandidatesTransactionsRoute = createApmServerRoute({ }, }); -const fieldStatsTransactionsRoute = createApmServerRoute({ - endpoint: 'POST /internal/apm/correlations/field_stats/transactions', - params: t.type({ - body: t.intersection([ - t.partial({ - serviceName: t.string, - transactionName: t.string, - transactionType: t.string, - }), - t.type({ - fieldsToSample: t.array(t.string), - }), - environmentRt, - kueryRt, - rangeRt, - ]), - }), - options: { tags: ['access:apm'] }, - handler: async ( - resources - ): Promise<{ - stats: Array< - import('./../../../common/correlations/field_stats_types').FieldStats - >; - errors: any[]; - }> => { - const { context } = resources; - const { license } = await context.licensing; - if (!isActivePlatinumLicense(license)) { - throw Boom.forbidden(INVALID_LICENSE); - } - - const apmEventClient = await getApmEventClient(resources); - - const { - body: { - serviceName, - transactionName, - transactionType, - start, - end, - environment, - kuery, - fieldsToSample, - }, - } = resources.params; - - return fetchFieldsStats({ - apmEventClient, - eventType: ProcessorEvent.transaction, - start, - end, - environment, - kuery, - query: { - bool: { - filter: [ - ...termQuery(SERVICE_NAME, serviceName), - ...termQuery(TRANSACTION_TYPE, transactionType), - ...termQuery(TRANSACTION_NAME, transactionName), - ], - }, - }, - fieldsToSample, - }); - }, -}); - const fieldValueStatsTransactionsRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/correlations/field_value_stats/transactions', params: t.type({ @@ -167,6 +98,7 @@ const fieldValueStatsTransactionsRoute = createApmServerRoute({ serviceName: t.string, transactionName: t.string, transactionType: t.string, + samplerShardSize: t.string, }), environmentRt, kueryRt, @@ -202,9 +134,13 @@ const fieldValueStatsTransactionsRoute = createApmServerRoute({ kuery, fieldName, fieldValue, + samplerShardSize: samplerShardSizeStr, }, } = resources.params; + const samplerShardSize = samplerShardSizeStr + ? parseInt(samplerShardSizeStr, 10) + : undefined; return fetchFieldValueFieldStats({ apmEventClient, eventType: ProcessorEvent.transaction, @@ -225,6 +161,7 @@ const fieldValueStatsTransactionsRoute = createApmServerRoute({ fieldName, fieldValue, }, + samplerShardSize, }); }, }); @@ -441,7 +378,6 @@ const pValuesTransactionsRoute = createApmServerRoute({ export const correlationsRouteRepository = { ...fieldCandidatesTransactionsRoute, - ...fieldStatsTransactionsRoute, ...fieldValueStatsTransactionsRoute, ...fieldValuePairsTransactionsRoute, ...significantCorrelationsTransactionsRoute, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 4150391405e79..674f0976028a3 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -6787,9 +6787,6 @@ "xpack.apm.compositeSpanCallsLabel": ", {count} appels, sur une moyenne de {duration}", "xpack.apm.correlations.ccsWarningCalloutBody": "Les données pour l'analyse de corrélation n'ont pas pu être totalement récupérées. Cette fonctionnalité est prise en charge uniquement à partir des versions {version} et ultérieures.", "xpack.apm.correlations.failedTransactions.helpPopover.basicExplanation": "Les corrélations vous aident à découvrir les attributs qui ont le plus d'influence pour distinguer les échecs et les succès d'une transaction. Les transactions sont considérées comme un échec lorsque leur valeur {field} est {value}.", - "xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel": "Filtrer sur le {fieldName} : \"{value}\"", - "xpack.apm.correlations.fieldContextPopover.calculatedFromSampleDescription": "Calculé à partir d'un échantillon de {sampleSize} documents", - "xpack.apm.correlations.fieldContextPopover.removeFilterAriaLabel": "Exclure le {fieldName} : \"{value}\"", "xpack.apm.correlations.progressTitle": "Progression : {progress} %", "xpack.apm.durationDistribution.chart.percentileMarkerLabel": "{markerPercentile}e centile", "xpack.apm.durationDistributionChart.totalSpansCount": "Total : {totalDocCount} {totalDocCount, plural, one {intervalle} other {intervalles}}", @@ -7093,7 +7090,6 @@ "xpack.apm.correlations.failedTransactions.panelTitle": "Distribution de la latence des transactions ayant échoué", "xpack.apm.correlations.failedTransactions.tableTitle": "Corrélations", "xpack.apm.correlations.fieldContextPopover.descriptionTooltipContent": "Afficher le top 10 des valeurs de champ", - "xpack.apm.correlations.fieldContextPopover.fieldTopValuesLabel": "Top 10 des valeurs", "xpack.apm.correlations.fieldContextPopover.notTopTenValueMessage": "Le terme sélectionné n'est pas dans le top 10", "xpack.apm.correlations.fieldContextPopover.topFieldValuesAriaLabel": "Afficher le top 10 des valeurs de champ", "xpack.apm.correlations.highImpactText": "Élevé", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 84bd08f5ecc0c..f1c6fb85d2648 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6777,9 +6777,6 @@ "xpack.apm.compositeSpanCallsLabel": "、{count}件の呼び出し、平均{duration}", "xpack.apm.correlations.ccsWarningCalloutBody": "相関関係分析のデータを完全に取得できませんでした。この機能は{version}以降でのみサポートされています。", "xpack.apm.correlations.failedTransactions.helpPopover.basicExplanation": "相関関係では、トランザクションの失敗と成功を区別するうえで最も影響度が大きい属性を見つけることができます。{field}値が{value}のときには、トランザクションが失敗であると見なされます。", - "xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel": "{fieldName}のフィルター:\"{value}\"", - "xpack.apm.correlations.fieldContextPopover.calculatedFromSampleDescription": "{sampleSize}ドキュメントのサンプルから計算済み", - "xpack.apm.correlations.fieldContextPopover.removeFilterAriaLabel": "{fieldName}の除外:\"{value}\"", "xpack.apm.correlations.progressTitle": "進捗状況: {progress}%", "xpack.apm.durationDistributionChart.totalSpansCount": "{totalDocCount}合計{totalDocCount, plural, other {個のスパン}}", "xpack.apm.durationDistributionChart.totalTransactionsCount": "{totalDocCount}合計{totalDocCount, plural, other {個のトランザクション}}", @@ -7081,7 +7078,6 @@ "xpack.apm.correlations.failedTransactions.panelTitle": "失敗したトランザクションの遅延分布", "xpack.apm.correlations.failedTransactions.tableTitle": "相関関係", "xpack.apm.correlations.fieldContextPopover.descriptionTooltipContent": "上位10フィールド値を表示", - "xpack.apm.correlations.fieldContextPopover.fieldTopValuesLabel": "上位10の値", "xpack.apm.correlations.fieldContextPopover.notTopTenValueMessage": "選択した用語は上位10件にありません", "xpack.apm.correlations.fieldContextPopover.topFieldValuesAriaLabel": "上位10フィールド値を表示", "xpack.apm.correlations.highImpactText": "高", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 425be938a865e..d29e13df48fc6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6791,9 +6791,6 @@ "xpack.apm.compositeSpanCallsLabel": ",{count} 个调用,平均 {duration}", "xpack.apm.correlations.ccsWarningCalloutBody": "无法完全检索相关性分析的数据。仅 {version} 及更高版本支持此功能。", "xpack.apm.correlations.failedTransactions.helpPopover.basicExplanation": "相关性将帮助您发现哪些属性在区分事务失败与成功时具有最大影响。如果事务的 {field} 值为 {value},则认为其失败。", - "xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel": "筛留 {fieldName}:“{value}”", - "xpack.apm.correlations.fieldContextPopover.calculatedFromSampleDescription": "基于 {sampleSize} 文档样例计算", - "xpack.apm.correlations.fieldContextPopover.removeFilterAriaLabel": "筛除 {fieldName}:“{value}”", "xpack.apm.correlations.progressTitle": "进度:{progress}%", "xpack.apm.durationDistribution.chart.percentileMarkerLabel": "第 {markerPercentile} 个百分位数", "xpack.apm.durationDistributionChart.totalSpansCount": "共 {totalDocCount} 个{totalDocCount, plural, other {跨度}}", @@ -7097,7 +7094,6 @@ "xpack.apm.correlations.failedTransactions.panelTitle": "失败事务延迟分布", "xpack.apm.correlations.failedTransactions.tableTitle": "相关性", "xpack.apm.correlations.fieldContextPopover.descriptionTooltipContent": "显示排名前 10 字段值", - "xpack.apm.correlations.fieldContextPopover.fieldTopValuesLabel": "排名前 10 值", "xpack.apm.correlations.fieldContextPopover.notTopTenValueMessage": "选定的词未排名前 10", "xpack.apm.correlations.fieldContextPopover.topFieldValuesAriaLabel": "显示排名前 10 字段值", "xpack.apm.correlations.highImpactText": "高", diff --git a/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.spec.ts b/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.spec.ts index e8e06b10794c9..e9c8167b0a878 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.spec.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.spec.ts @@ -183,16 +183,6 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); } - const failedtransactionsFieldStats = await apmApiClient.readUser({ - endpoint: 'POST /internal/apm/correlations/field_stats/transactions', - params: { - body: { - ...getOptions(), - fieldsToSample: [...fieldsToSample], - }, - }, - }); - const finalRawResponse: FailedTransactionsCorrelationsResponse = { ccsWarning: failedTransactionsCorrelationsResponse.body?.ccsWarning, percentileThresholdValue: overallDistributionResponse.body?.percentileThresholdValue, @@ -200,13 +190,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { errorHistogram: errorDistributionResponse.body?.overallHistogram, failedTransactionsCorrelations: failedTransactionsCorrelationsResponse.body?.failedTransactionsCorrelations, - fieldStats: failedtransactionsFieldStats.body?.stats, }; expect(finalRawResponse?.percentileThresholdValue).to.be(1309695.875); expect(finalRawResponse?.errorHistogram?.length).to.be(101); expect(finalRawResponse?.overallHistogram?.length).to.be(101); - expect(finalRawResponse?.fieldStats?.length).to.be(fieldsToSample.size); expect(finalRawResponse?.failedTransactionsCorrelations?.length).to.eql( 30, @@ -228,13 +216,6 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(typeof correlation?.normalizedScore).to.be('number'); expect(typeof correlation?.failurePercentage).to.be('number'); expect(typeof correlation?.successPercentage).to.be('number'); - - const fieldStats = finalRawResponse?.fieldStats?.[0]; - expect(typeof fieldStats).to.be('object'); - expect(Array.isArray(fieldStats?.topValues) && fieldStats?.topValues?.length).to.greaterThan( - 0 - ); - expect(fieldStats?.topValuesSampleSize).to.greaterThan(0); }); }); } diff --git a/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts b/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts index d0eea80dcf1c0..a4edfd1d5ab00 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts @@ -220,28 +220,16 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); } - const failedtransactionsFieldStats = await apmApiClient.readUser({ - endpoint: 'POST /internal/apm/correlations/field_stats/transactions', - params: { - body: { - ...getOptions(), - fieldsToSample: [...fieldsToSample], - }, - }, - }); - const finalRawResponse: LatencyCorrelationsResponse = { ccsWarning, percentileThresholdValue: overallDistributionResponse.body?.percentileThresholdValue, overallHistogram: overallDistributionResponse.body?.overallHistogram, latencyCorrelations, - fieldStats: failedtransactionsFieldStats.body?.stats, }; // Fetched 95th percentile value of 1309695.875 based on 1244 documents. expect(finalRawResponse?.percentileThresholdValue).to.be(1309695.875); expect(finalRawResponse?.overallHistogram?.length).to.be(101); - expect(finalRawResponse?.fieldStats?.length).to.be(fieldsToSample.size); // Identified 13 significant correlations out of 379 field/value pairs. expect(finalRawResponse?.latencyCorrelations?.length).to.eql( @@ -258,13 +246,6 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(correlation?.correlation).to.be(0.6275246559191225); expect(correlation?.ksTest).to.be(4.806503252860024e-13); expect(correlation?.histogram?.length).to.be(101); - - const fieldStats = finalRawResponse?.fieldStats?.[0]; - expect(typeof fieldStats).to.be('object'); - expect( - Array.isArray(fieldStats?.topValues) && fieldStats?.topValues?.length - ).to.greaterThan(0); - expect(fieldStats?.topValuesSampleSize).to.greaterThan(0); }); } ); From 235553597226650fed3f13efe2762d63cd342e7b Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Tue, 15 Nov 2022 13:33:31 -0500 Subject: [PATCH 07/13] [Guided onboarding] Update getting started page breadcrumb (#145250) --- .../components/guided_onboarding/getting_started.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx b/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx index 7a6992da521b6..f1b4802c739a1 100644 --- a/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx +++ b/src/plugins/home/public/application/components/guided_onboarding/getting_started.tsx @@ -33,7 +33,7 @@ import { KEY_ENABLE_WELCOME } from '../home'; const homeBreadcrumb = i18n.translate('home.breadcrumbs.homeTitle', { defaultMessage: 'Home' }); const gettingStartedBreadcrumb = i18n.translate('home.breadcrumbs.gettingStartedTitle', { - defaultMessage: 'Guided setup', + defaultMessage: 'Setup guides', }); const title = i18n.translate('home.guidedOnboarding.gettingStarted.useCaseSelectionTitle', { defaultMessage: 'What would you like to do first?', From 4868e2118dd26279692021081a1442231397cda1 Mon Sep 17 00:00:00 2001 From: Khristinin Nikita Date: Tue, 15 Nov 2022 19:43:40 +0100 Subject: [PATCH 08/13] Rule duplication with/without exceptions (#144782) ## Rule duplication with/without exceptions Majority of work done by @yctercero in this [branch](https://github.com/yctercero/kibana/tree/dupe) Some integration tests are left, but PR is ready for review. 2 flow when you duplicate rule: ### Without exceptions Don't duplicate any exceptions ### With exceptions Shared exceptions should duplicate reference Rule default exceptions are not duplicated by reference, but create a copy of exceptions. So if you remove it from duplicate rules, the original rule is not changed. https://user-images.githubusercontent.com/7609147/200863319-4cb56749-42dd-42d8-8896-f45782c21838.mov # TODO; [] integrations tests [] cypress tests Co-authored-by: Yara Tercero Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../bulk_create_exception_list_items.ts | 69 ++++++++ .../duplicate_exception_list.ts | 117 ++++++++++++++ .../exception_lists/exception_list_client.ts | 21 +++ .../exception_list_client_types.ts | 11 ++ .../rules/bulk_actions/request_schema.test.ts | 1 + .../api/rules/bulk_actions/request_schema.ts | 21 ++- .../rule_management/constants.ts | 11 ++ .../cypress/screens/alerts_detection_rules.ts | 1 + .../cypress/tasks/alerts_detection_rules.ts | 4 + .../pages/rule_details/index.tsx | 17 ++ .../rule_management/api/api.ts | 21 ++- ...bulk_duplicate_exceptions_confirmation.tsx | 77 +++++++++ .../rules_table/bulk_actions/translations.tsx | 56 +++++++ .../bulk_actions/use_bulk_actions.tsx | 12 ++ .../use_bulk_duplicate_confirmation.ts | 53 +++++++ .../components/rules_table/rules_tables.tsx | 19 +++ .../components/rules_table/use_columns.tsx | 22 ++- .../rules_table/use_rules_table_actions.tsx | 15 +- .../rule_actions_overflow/index.test.tsx | 61 +++---- .../rules/rule_actions_overflow/index.tsx | 14 ++ .../api/create_rule_exceptions/route.ts | 150 ++++++++++-------- .../api/rules/bulk_actions/route.ts | 35 +++- .../logic/actions/duplicate_exceptions.ts | 62 ++++++++ .../logic/actions/duplicate_rule.test.ts | 72 ++++++--- .../logic/actions/duplicate_rule.ts | 8 +- .../group10/perform_bulk_action.ts | 12 +- 26 files changed, 817 insertions(+), 145 deletions(-) create mode 100644 x-pack/plugins/lists/server/services/exception_lists/bulk_create_exception_list_items.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/duplicate_exception_list.ts create mode 100644 x-pack/plugins/security_solution/common/detection_engine/rule_management/constants.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_duplicate_exceptions_confirmation.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_duplicate_confirmation.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_exceptions.ts diff --git a/x-pack/plugins/lists/server/services/exception_lists/bulk_create_exception_list_items.ts b/x-pack/plugins/lists/server/services/exception_lists/bulk_create_exception_list_items.ts new file mode 100644 index 0000000000000..de21a883e9ab3 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/bulk_create_exception_list_items.ts @@ -0,0 +1,69 @@ +/* + * 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 { SavedObjectsClientContract } from '@kbn/core/server'; +import uuid from 'uuid'; +import type { + CreateExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { SavedObjectType, getSavedObjectType } from '@kbn/securitysolution-list-utils'; + +import { ExceptionListSoSchema } from '../../schemas/saved_objects'; + +import { transformSavedObjectToExceptionListItem } from './utils'; + +interface BulkCreateExceptionListItemsOptions { + items: CreateExceptionListItemSchema[]; + savedObjectsClient: SavedObjectsClientContract; + user: string; + tieBreaker?: string; +} + +export const bulkCreateExceptionListItems = async ({ + items, + savedObjectsClient, + tieBreaker, + user, +}: BulkCreateExceptionListItemsOptions): Promise => { + const formattedItems = items.map((item) => { + const savedObjectType = getSavedObjectType({ namespaceType: item.namespace_type ?? 'single' }); + const dateNow = new Date().toISOString(); + + return { + attributes: { + comments: [], + created_at: dateNow, + created_by: user, + description: item.description, + entries: item.entries, + immutable: false, + item_id: item.item_id, + list_id: item.list_id, + list_type: 'item', + meta: item.meta, + name: item.name, + os_types: item.os_types, + tags: item.tags, + tie_breaker_id: tieBreaker ?? uuid.v4(), + type: item.type, + updated_by: user, + version: undefined, + }, + type: savedObjectType, + } as { attributes: ExceptionListSoSchema; type: SavedObjectType }; + }); + + const { saved_objects: savedObjects } = + await savedObjectsClient.bulkCreate(formattedItems); + + const result = savedObjects.map((so) => + transformSavedObjectToExceptionListItem({ savedObject: so }) + ); + + return result; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/duplicate_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/duplicate_exception_list.ts new file mode 100644 index 0000000000000..f2d0723590cf5 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/duplicate_exception_list.ts @@ -0,0 +1,117 @@ +/* + * 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 { SavedObjectsClientContract } from '@kbn/core/server'; +import uuid from 'uuid'; +import { + CreateExceptionListItemSchema, + ExceptionListSchema, + ExceptionListTypeEnum, + FoundExceptionListItemSchema, + ListId, + NamespaceType, +} from '@kbn/securitysolution-io-ts-list-types'; + +import { findExceptionListsItemPointInTimeFinder } from './find_exception_list_items_point_in_time_finder'; +import { bulkCreateExceptionListItems } from './bulk_create_exception_list_items'; +import { getExceptionList } from './get_exception_list'; +import { createExceptionList } from './create_exception_list'; + +const LISTS_ABLE_TO_DUPLICATE = [ + ExceptionListTypeEnum.DETECTION.toString(), + ExceptionListTypeEnum.RULE_DEFAULT.toString(), +]; + +interface CreateExceptionListOptions { + listId: ListId; + savedObjectsClient: SavedObjectsClientContract; + namespaceType: NamespaceType; + user: string; +} + +export const duplicateExceptionListAndItems = async ({ + listId, + savedObjectsClient, + namespaceType, + user, +}: CreateExceptionListOptions): Promise => { + // Generate a new static listId + const newListId = uuid.v4(); + + // fetch list container + const listToDuplicate = await getExceptionList({ + id: undefined, + listId, + namespaceType, + savedObjectsClient, + }); + + if (listToDuplicate == null) { + throw new Error(`Exception list to duplicat of list_id:${listId} not found.`); + } + + if (!LISTS_ABLE_TO_DUPLICATE.includes(listToDuplicate.type)) { + throw new Error(`Exception list of type:${listToDuplicate.type} cannot be duplicated.`); + } + + const newlyCreatedList = await createExceptionList({ + description: listToDuplicate.description, + immutable: listToDuplicate.immutable, + listId: newListId, + meta: listToDuplicate.meta, + name: listToDuplicate.name, + namespaceType: listToDuplicate.namespace_type, + savedObjectsClient, + tags: listToDuplicate.tags, + type: listToDuplicate.type, + user, + version: 1, + }); + + // fetch associated items + let itemsToBeDuplicated: CreateExceptionListItemSchema[] = []; + const executeFunctionOnStream = (response: FoundExceptionListItemSchema): void => { + const transformedItems = response.data.map((item) => { + // Generate a new static listId + const newItemId = uuid.v4(); + + return { + comments: [], + description: item.description, + entries: item.entries, + item_id: newItemId, + list_id: newlyCreatedList.list_id, + meta: item.meta, + name: item.name, + namespace_type: item.namespace_type, + os_types: item.os_types, + tags: item.tags, + type: item.type, + }; + }); + itemsToBeDuplicated = [...itemsToBeDuplicated, ...transformedItems]; + }; + await findExceptionListsItemPointInTimeFinder({ + executeFunctionOnStream, + filter: [], + listId: [listId], + maxSize: 10000, + namespaceType: [namespaceType], + perPage: undefined, + savedObjectsClient, + sortField: undefined, + sortOrder: undefined, + }); + + await bulkCreateExceptionListItems({ + items: itemsToBeDuplicated, + savedObjectsClient, + user, + }); + + return newlyCreatedList; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index ecdaa70d7869b..562ebffc9f00c 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -39,6 +39,7 @@ import type { DeleteExceptionListItemByIdOptions, DeleteExceptionListItemOptions, DeleteExceptionListOptions, + DuplicateExceptionListOptions, ExportExceptionListAndItemsOptions, FindEndpointListItemOptions, FindExceptionListItemOptions, @@ -95,6 +96,7 @@ import { findValueListExceptionListItems } from './find_value_list_exception_lis import { findExceptionListsItemPointInTimeFinder } from './find_exception_list_items_point_in_time_finder'; import { findValueListExceptionListItemsPointInTimeFinder } from './find_value_list_exception_list_items_point_in_time_finder'; import { findExceptionListItemPointInTimeFinder } from './find_exception_list_item_point_in_time_finder'; +import { duplicateExceptionListAndItems } from './duplicate_exception_list'; /** * Class for use for exceptions that are with trusted applications or @@ -311,6 +313,25 @@ export class ExceptionListClient { }); }; + /** + * Create the Trusted Apps Agnostic list if it does not yet exist (`null` is returned if it does exist) + * @param options.listId the "list_id" of the exception list + * @param options.namespaceType saved object namespace (single | agnostic) + * @returns The exception list schema or null if it does not exist + */ + public duplicateExceptionListAndItems = async ({ + listId, + namespaceType, + }: DuplicateExceptionListOptions): Promise => { + const { savedObjectsClient, user } = this; + return duplicateExceptionListAndItems({ + listId, + namespaceType, + savedObjectsClient, + user, + }); + }; + /** * This is the same as "updateExceptionListItem" except it applies specifically to the endpoint list and will * auto-call the "createEndpointList" for you so that you have the best chance of the endpoint diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 35a28c0116035..6b87945710a37 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -287,6 +287,17 @@ export interface CreateEndpointListItemOptions { type: ExceptionListItemType; } +/** + * ExceptionListClient.duplicateExceptionListAndItems + * {@link ExceptionListClient.duplicateExceptionListAndItems} + */ +export interface DuplicateExceptionListOptions { + /** The single list id to do the search against */ + listId: ListId; + /** saved object namespace (single | agnostic) */ + namespaceType: NamespaceType; +} + /** * ExceptionListClient.updateExceptionListItem * {@link ExceptionListClient.updateExceptionListItem} diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.test.ts index 99f5413e6688b..3cee4c3dbe384 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.test.ts @@ -133,6 +133,7 @@ describe('Perform bulk action request schema', () => { const payload: PerformBulkActionRequestBody = { query: 'name: test', action: BulkActionType.duplicate, + [BulkActionType.duplicate]: { include_exceptions: false }, }; const message = retrieveValidationMessage(payload); expect(getPaths(left(message.errors))).toEqual([]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.ts index c09a2c27ea576..d595dc88441cc 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/bulk_actions/request_schema.ts @@ -131,6 +131,14 @@ export const BulkActionEditPayload = t.union([ BulkActionEditPayloadSchedule, ]); +const bulkActionDuplicatePayload = t.exact( + t.type({ + include_exceptions: t.boolean, + }) +); + +export type BulkActionDuplicatePayload = t.TypeOf; + /** * actions that modify rules attributes */ @@ -164,12 +172,23 @@ export const PerformBulkActionRequestBody = t.intersection([ action: t.union([ t.literal(BulkActionType.delete), t.literal(BulkActionType.disable), - t.literal(BulkActionType.duplicate), t.literal(BulkActionType.enable), t.literal(BulkActionType.export), ]), }) ), + t.intersection([ + t.exact( + t.type({ + action: t.literal(BulkActionType.duplicate), + }) + ), + t.exact( + t.partial({ + [BulkActionType.duplicate]: bulkActionDuplicatePayload, + }) + ), + ]), t.exact( t.type({ action: t.literal(BulkActionType.edit), diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/constants.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/constants.ts new file mode 100644 index 0000000000000..710c0b55a86f9 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/constants.ts @@ -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. + */ + +export enum DuplicateOptions { + withExceptions = 'withExceptions', + withoutExceptions = 'withoutExceptions', +} diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index 5452d59b68c07..32c6a336b9096 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -22,6 +22,7 @@ export const EDIT_RULE_ACTION_BTN = '[data-test-subj="editRuleAction"]'; export const DUPLICATE_RULE_ACTION_BTN = '[data-test-subj="duplicateRuleAction"]'; export const DUPLICATE_RULE_MENU_PANEL_BTN = '[data-test-subj="rules-details-duplicate-rule"]'; +export const CONFIRM_DUPLICATE_RULE = '[data-test-subj="confirmModalConfirmButton"]'; export const ENABLE_RULE_BULK_BTN = '[data-test-subj="enableRuleBulk"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 2bc4badf3cfa1..836eae6076f17 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -31,6 +31,7 @@ import { DUPLICATE_RULE_ACTION_BTN, DUPLICATE_RULE_MENU_PANEL_BTN, DUPLICATE_RULE_BULK_BTN, + CONFIRM_DUPLICATE_RULE, RULES_ROW, SELECT_ALL_RULES_BTN, MODAL_CONFIRMATION_BTN, @@ -80,6 +81,7 @@ export const duplicateFirstRule = () => { cy.get(COLLAPSED_ACTION_BTN).first().click({ force: true }); cy.get(DUPLICATE_RULE_ACTION_BTN).should('be.visible'); cy.get(DUPLICATE_RULE_ACTION_BTN).click(); + cy.get(CONFIRM_DUPLICATE_RULE).click(); }; /** @@ -96,6 +98,7 @@ export const duplicateRuleFromMenu = () => { // Because of a fade effect and fast clicking this can produce more than one click cy.get(DUPLICATE_RULE_MENU_PANEL_BTN).pipe(click); + cy.get(CONFIRM_DUPLICATE_RULE).click(); }; /** @@ -138,6 +141,7 @@ export const duplicateSelectedRules = () => { cy.log('Duplicate selected rules'); cy.get(BULK_ACTIONS_BTN).click({ force: true }); cy.get(DUPLICATE_RULE_BULK_BTN).click(); + cy.get(CONFIRM_DUPLICATE_RULE).click(); }; export const enableSelectedRules = () => { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index 0aa6fa9c20875..38dbf65542254 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -133,6 +133,8 @@ import { ExceptionsViewer } from '../../../rule_exceptions/components/all_except import type { NavTab } from '../../../../common/components/navigation/types'; import { EditRuleSettingButtonLink } from '../../../../detections/pages/detection_engine/rules/details/components/edit_rule_settings_button_link'; import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs'; +import { useBulkDuplicateExceptionsConfirmation } from '../../../rule_management_ui/components/rules_table/bulk_actions/use_bulk_duplicate_confirmation'; +import { BulkActionDuplicateExceptionsConfirmation } from '../../../rule_management_ui/components/rules_table/bulk_actions/bulk_duplicate_exceptions_confirmation'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. @@ -625,6 +627,13 @@ const RuleDetailsPageComponent: React.FC = ({ [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] ); + const { + isBulkDuplicateConfirmationVisible, + showBulkDuplicateConfirmation, + cancelRuleDuplication, + confirmRuleDuplication, + } = useBulkDuplicateExceptionsConfirmation(); + if ( redirectToDetections( isSignalIndexExists, @@ -646,6 +655,13 @@ const RuleDetailsPageComponent: React.FC = ({ <> + {isBulkDuplicateConfirmationVisible && ( + + )} @@ -736,6 +752,7 @@ const RuleDetailsPageComponent: React.FC = ({ rule, hasActionsPrivileges )} + showBulkDuplicateExceptionsConfirmation={showBulkDuplicateConfirmation} />
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 40a00178c31b8..7c113670d2f3a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -28,7 +28,10 @@ import { import type { RulesReferencedByExceptionListsSchema } from '../../../../common/detection_engine/rule_exceptions'; import { DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL } from '../../../../common/detection_engine/rule_exceptions'; -import type { BulkActionEditPayload } from '../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; +import type { + BulkActionEditPayload, + BulkActionDuplicatePayload, +} from '../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { BulkActionType } from '../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import type { @@ -215,13 +218,23 @@ export interface BulkActionResponse { export type QueryOrIds = { query: string; ids?: undefined } | { query?: undefined; ids: string[] }; type PlainBulkAction = { - type: Exclude; + type: Exclude< + BulkActionType, + BulkActionType.edit | BulkActionType.export | BulkActionType.duplicate + >; } & QueryOrIds; + type EditBulkAction = { type: BulkActionType.edit; editPayload: BulkActionEditPayload[]; } & QueryOrIds; -export type BulkAction = PlainBulkAction | EditBulkAction; + +type DuplicateBulkAction = { + type: BulkActionType.duplicate; + duplicatePayload?: BulkActionDuplicatePayload; +} & QueryOrIds; + +export type BulkAction = PlainBulkAction | EditBulkAction | DuplicateBulkAction; export interface PerformBulkActionProps { bulkAction: BulkAction; @@ -245,6 +258,8 @@ export async function performBulkAction({ query: bulkAction.query, ids: bulkAction.ids, edit: bulkAction.type === BulkActionType.edit ? bulkAction.editPayload : undefined, + duplicate: + bulkAction.type === BulkActionType.duplicate ? bulkAction.duplicatePayload : undefined, }; return KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_BULK_ACTION, { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_duplicate_exceptions_confirmation.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_duplicate_exceptions_confirmation.tsx new file mode 100644 index 0000000000000..cd8be2d925c3f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/bulk_duplicate_exceptions_confirmation.tsx @@ -0,0 +1,77 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { EuiRadioGroup, EuiText, EuiConfirmModal, EuiSpacer, EuiIconTip } from '@elastic/eui'; +import { DuplicateOptions } from '../../../../../../common/detection_engine/rule_management/constants'; + +import { bulkDuplicateRuleActions as i18n } from './translations'; + +interface BulkDuplicateExceptionsConfirmationProps { + onCancel: () => void; + onConfirm: (s: string) => void; + rulesCount: number; +} + +const BulkActionDuplicateExceptionsConfirmationComponent = ({ + onCancel, + onConfirm, + rulesCount, +}: BulkDuplicateExceptionsConfirmationProps) => { + const [selectedDuplicateOption, setSelectedDuplicateOption] = useState( + DuplicateOptions.withExceptions + ); + + const handleRadioChange = useCallback( + (optionId) => { + setSelectedDuplicateOption(optionId); + }, + [setSelectedDuplicateOption] + ); + + const handleConfirm = useCallback(() => { + onConfirm(selectedDuplicateOption); + }, [onConfirm, selectedDuplicateOption]); + + return ( + + + {i18n.MODAL_TEXT(rulesCount)}{' '} + + + + + + + ); +}; + +export const BulkActionDuplicateExceptionsConfirmation = React.memo( + BulkActionDuplicateExceptionsConfirmationComponent +); + +BulkActionDuplicateExceptionsConfirmation.displayName = 'BulkActionDuplicateExceptionsConfirmation'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/translations.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/translations.tsx index cdeec1aeb4adb..1b28952acd7d8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/translations.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/translations.tsx @@ -134,3 +134,59 @@ export const bulkSetSchedule = { /> ), }; + +export const bulkDuplicateRuleActions = { + MODAL_TITLE: (rulesCount: number): JSX.Element => ( + + ), + + MODAL_TEXT: (rulesCount: number): JSX.Element => ( + + ), + + DUPLICATE_EXCEPTIONS_TEXT: (rulesCount: number) => ( + + ), + + DUPLICATE_WITHOUT_EXCEPTIONS_TEXT: (rulesCount: number) => ( + + ), + + CONTINUE_BUTTON: i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.continueButton', + { + defaultMessage: 'Duplicate', + } + ), + + CANCEL_BUTTON: i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.cancelButton', + { + defaultMessage: 'Cancel', + } + ), + + DUPLICATE_TOOLTIP: i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.exceptionsConfirmation.tooltip', + { + defaultMessage: + ' If you duplicate exceptions, then the shared exceptions list will be duplicated by reference and the default rule exception will be copied and created as a new one', + } + ), +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx index 74c2471b00f7e..68e7c0030bc2a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx @@ -12,6 +12,7 @@ import type { Toast } from '@kbn/core/public'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { euiThemeVars } from '@kbn/ui-theme'; import React, { useCallback } from 'react'; +import { DuplicateOptions } from '../../../../../../common/detection_engine/rule_management/constants'; import type { BulkActionEditPayload } from '../../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { BulkActionType, @@ -46,6 +47,7 @@ interface UseBulkActionsArgs { result: DryRunResult | undefined, action: BulkActionForConfirmation ) => Promise; + showBulkDuplicateConfirmation: () => Promise; completeBulkEditForm: ( bulkActionEditType: BulkActionEditType ) => Promise; @@ -56,6 +58,7 @@ export const useBulkActions = ({ filterOptions, confirmDeletion, showBulkActionConfirmation, + showBulkDuplicateConfirmation, completeBulkEditForm, executeBulkActionsDryRun, }: UseBulkActionsArgs) => { @@ -125,8 +128,16 @@ export const useBulkActions = ({ startTransaction({ name: BULK_RULE_ACTIONS.DUPLICATE }); closePopover(); + const modalDuplicationConfirmationResult = await showBulkDuplicateConfirmation(); + if (modalDuplicationConfirmationResult === null) { + return; + } await executeBulkAction({ type: BulkActionType.duplicate, + duplicatePayload: { + include_exceptions: + modalDuplicationConfirmationResult === DuplicateOptions.withExceptions, + }, ...(isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }), }); clearRulesSelection(); @@ -461,6 +472,7 @@ export const useBulkActions = ({ filterOptions, completeBulkEditForm, downloadExportedRules, + showBulkDuplicateConfirmation, ] ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_duplicate_confirmation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_duplicate_confirmation.ts new file mode 100644 index 0000000000000..9090eba69b009 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_duplicate_confirmation.ts @@ -0,0 +1,53 @@ +/* + * 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 { useCallback, useRef } from 'react'; + +import { useBoolState } from '../../../../../common/hooks/use_bool_state'; + +/** + * hook that controls bulk duplicate actions exceptions confirmation modal window and its content + */ +export const useBulkDuplicateExceptionsConfirmation = () => { + const [isBulkDuplicateConfirmationVisible, showModal, hideModal] = useBoolState(); + const confirmationPromiseRef = useRef<(result: string | null) => void>(); + + const onConfirm = useCallback((value: string) => { + confirmationPromiseRef.current?.(value); + }, []); + + const onCancel = useCallback(() => { + confirmationPromiseRef.current?.(null); + }, []); + + const initModal = useCallback(() => { + showModal(); + + return new Promise((resolve) => { + confirmationPromiseRef.current = resolve; + }).finally(() => { + hideModal(); + }); + }, [showModal, hideModal]); + + const showBulkDuplicateConfirmation = useCallback(async () => { + const confirmation = await initModal(); + if (confirmation) { + onConfirm(confirmation); + } else { + onCancel(); + } + + return confirmation; + }, [initModal, onConfirm, onCancel]); + + return { + isBulkDuplicateConfirmationVisible, + showBulkDuplicateConfirmation, + cancelRuleDuplication: onCancel, + confirmRuleDuplication: onConfirm, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx index 163402ac7bfd7..fa063dbc98242 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_tables.tsx @@ -36,6 +36,8 @@ import { RulesTableUtilityBar } from './rules_table_utility_bar'; import { useMonitoringColumns, useRulesColumns } from './use_columns'; import { useUserData } from '../../../../detections/components/user_info'; import { hasUserCRUDPermission } from '../../../../common/utils/privileges'; +import { useBulkDuplicateExceptionsConfirmation } from './bulk_actions/use_bulk_duplicate_confirmation'; +import { BulkActionDuplicateExceptionsConfirmation } from './bulk_actions/bulk_duplicate_exceptions_confirmation'; import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs'; const INITIAL_SORT_FIELD = 'enabled'; @@ -101,6 +103,13 @@ export const RulesTables = React.memo(({ selectedTab }) => { approveBulkActionConfirmation, } = useBulkActionsConfirmation(); + const { + isBulkDuplicateConfirmationVisible, + showBulkDuplicateConfirmation, + cancelRuleDuplication, + confirmRuleDuplication, + } = useBulkDuplicateExceptionsConfirmation(); + const { bulkEditActionType, isBulkEditFlyoutVisible, @@ -115,6 +124,7 @@ export const RulesTables = React.memo(({ selectedTab }) => { filterOptions, confirmDeletion, showBulkActionConfirmation, + showBulkDuplicateConfirmation, completeBulkEditForm, executeBulkActionsDryRun, }); @@ -147,12 +157,14 @@ export const RulesTables = React.memo(({ selectedTab }) => { isLoadingJobs, mlJobs, startMlJobs, + showExceptionsDuplicateConfirmation: showBulkDuplicateConfirmation, }); const monitoringColumns = useMonitoringColumns({ hasCRUDPermissions: hasPermissions, isLoadingJobs, mlJobs, startMlJobs, + showExceptionsDuplicateConfirmation: showBulkDuplicateConfirmation, }); const isSelectAllCalled = useRef(false); @@ -259,6 +271,13 @@ export const RulesTables = React.memo(({ selectedTab }) => { onConfirm={approveBulkActionConfirmation} /> )} + {isBulkDuplicateConfirmationVisible && ( + + )} {isBulkEditFlyoutVisible && bulkEditActionType !== undefined && ( Promise; } +interface ActionColumnsProps { + showExceptionsDuplicateConfirmation: () => Promise; +} + const useEnabledColumn = ({ hasCRUDPermissions, startMlJobs }: ColumnsProps): TableColumn => { const hasMlPermissions = useHasMlPermissions(); const hasActionsPrivileges = useHasActionsPrivileges(); @@ -209,19 +213,24 @@ const INTEGRATIONS_COLUMN: TableColumn = { truncateText: true, }; -const useActionsColumn = (): EuiTableActionsColumnType => { - const actions = useRulesTableActions(); +const useActionsColumn = ({ + showExceptionsDuplicateConfirmation, +}: ActionColumnsProps): EuiTableActionsColumnType => { + const actions = useRulesTableActions({ showExceptionsDuplicateConfirmation }); return useMemo(() => ({ actions, width: '40px' }), [actions]); }; +export interface UseColumnsProps extends ColumnsProps, ActionColumnsProps {} + export const useRulesColumns = ({ hasCRUDPermissions, isLoadingJobs, mlJobs, startMlJobs, -}: ColumnsProps): TableColumn[] => { - const actionsColumn = useActionsColumn(); + showExceptionsDuplicateConfirmation, +}: UseColumnsProps): TableColumn[] => { + const actionsColumn = useActionsColumn({ showExceptionsDuplicateConfirmation }); const ruleNameColumn = useRuleNameColumn(); const { isInMemorySorting } = useRulesTableContext().state; const [showRelatedIntegrations] = useUiSetting$(SHOW_RELATED_INTEGRATIONS_SETTING); @@ -338,9 +347,10 @@ export const useMonitoringColumns = ({ isLoadingJobs, mlJobs, startMlJobs, -}: ColumnsProps): TableColumn[] => { + showExceptionsDuplicateConfirmation, +}: UseColumnsProps): TableColumn[] => { const docLinks = useKibana().services.docLinks; - const actionsColumn = useActionsColumn(); + const actionsColumn = useActionsColumn({ showExceptionsDuplicateConfirmation }); const ruleNameColumn = useRuleNameColumn(); const { isInMemorySorting } = useRulesTableContext().state; const [showRelatedIntegrations] = useUiSetting$(SHOW_RELATED_INTEGRATIONS_SETTING); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx index 185ca7040fe21..62af07af3ea24 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_rules_table_actions.tsx @@ -8,6 +8,7 @@ import type { DefaultItemAction } from '@elastic/eui'; import { EuiToolTip } from '@elastic/eui'; import React from 'react'; +import { DuplicateOptions } from '../../../../../common/detection_engine/rule_management/constants'; import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; @@ -23,7 +24,11 @@ import { import { useDownloadExportedRules } from '../../../rule_management/logic/bulk_actions/use_download_exported_rules'; import { useHasActionsPrivileges } from './use_has_actions_privileges'; -export const useRulesTableActions = (): Array> => { +export const useRulesTableActions = ({ + showExceptionsDuplicateConfirmation, +}: { + showExceptionsDuplicateConfirmation: () => Promise; +}): Array> => { const { navigateToApp } = useKibana().services.application; const hasActionsPrivileges = useHasActionsPrivileges(); const { startTransaction } = useStartTransaction(); @@ -63,9 +68,17 @@ export const useRulesTableActions = (): Array> => { // TODO extract those handlers to hooks, like useDuplicateRule onClick: async (rule: Rule) => { startTransaction({ name: SINGLE_RULE_ACTIONS.DUPLICATE }); + const modalDuplicationConfirmationResult = await showExceptionsDuplicateConfirmation(); + if (modalDuplicationConfirmationResult === null) { + return; + } const result = await executeBulkAction({ type: BulkActionType.duplicate, ids: [rule.id], + duplicatePayload: { + include_exceptions: + modalDuplicationConfirmationResult === DuplicateOptions.withExceptions, + }, }); const createdRules = result?.attributes.results.created; if (createdRules?.length) { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx index 816c2963779f9..0276d89ae1449 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx @@ -15,6 +15,8 @@ import { RuleActionsOverflow } from '.'; import { mockRule } from '../../../../detection_engine/rule_management_ui/components/rules_table/__mocks__/mock'; import { TestProviders } from '../../../../common/mock'; +const showBulkDuplicateExceptionsConfirmation = () => Promise.resolve(null); + jest.mock( '../../../../detection_engine/rule_management/logic/bulk_actions/use_execute_bulk_action' ); @@ -50,6 +52,7 @@ describe('RuleActionsOverflow', () => { test('menu items rendered when a rule is passed to the component', () => { const { getByTestId } = render( { test('menu is empty when no rule is passed to the component', () => { const { getByTestId } = render( - , + , { wrapper: TestProviders } ); fireEvent.click(getByTestId('rules-details-popover-button-icon')); @@ -76,6 +84,7 @@ describe('RuleActionsOverflow', () => { test('it does not open the popover when rules-details-popover-button-icon is clicked when the user does not have permission', () => { const { getByTestId } = render( { test('it closes the popover when rules-details-duplicate-rule is clicked', () => { const { getByTestId } = render( { expect(getByTestId('rules-details-popover')).not.toHaveTextContent(/.+/); }); - - test('it calls duplicate action when rules-details-duplicate-rule is clicked', () => { - const executeBulkAction = jest.fn(); - useExecuteBulkActionMock.mockReturnValue({ executeBulkAction }); - - const { getByTestId } = render( - , - { wrapper: TestProviders } - ); - fireEvent.click(getByTestId('rules-details-popover-button-icon')); - fireEvent.click(getByTestId('rules-details-duplicate-rule')); - - expect(executeBulkAction).toHaveBeenCalledWith( - expect.objectContaining({ type: 'duplicate' }) - ); - }); - - test('it calls duplicate action with the rule and rule.id when rules-details-duplicate-rule is clicked', () => { - const executeBulkAction = jest.fn(); - useExecuteBulkActionMock.mockReturnValue({ executeBulkAction }); - - const { getByTestId } = render( - , - { wrapper: TestProviders } - ); - fireEvent.click(getByTestId('rules-details-popover-button-icon')); - fireEvent.click(getByTestId('rules-details-duplicate-rule')); - - expect(executeBulkAction).toHaveBeenCalledWith({ type: 'duplicate', ids: ['id'] }); - }); }); describe('rules details export rule', () => { @@ -150,6 +122,7 @@ describe('RuleActionsOverflow', () => { const { getByTestId } = render( { test('it closes the popover when rules-details-export-rule is clicked', () => { const { getByTestId } = render( { test('it closes the popover when rules-details-delete-rule is clicked', () => { const { getByTestId } = render( { const { getByTestId } = render( { const rule = mockRule('id'); const { getByTestId } = render( - , + , { wrapper: TestProviders } ); fireEvent.click(getByTestId('rules-details-popover-button-icon')); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx index 1281e67a49b71..f5345f42f810b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx @@ -15,6 +15,7 @@ import { import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { APP_UI_ID, SecurityPageName } from '../../../../../common/constants'; +import { DuplicateOptions } from '../../../../../common/detection_engine/rule_management/constants'; import { BulkActionType } from '../../../../../common/detection_engine/rule_management/api/rules/bulk_actions/request_schema'; import { getRulesUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; import { useBoolState } from '../../../../common/hooks/use_bool_state'; @@ -47,6 +48,7 @@ interface RuleActionsOverflowComponentProps { rule: Rule | null; userHasPermissions: boolean; canDuplicateRuleWithActions: boolean; + showBulkDuplicateExceptionsConfirmation: () => Promise; } /** @@ -56,6 +58,7 @@ const RuleActionsOverflowComponent = ({ rule, userHasPermissions, canDuplicateRuleWithActions, + showBulkDuplicateExceptionsConfirmation, }: RuleActionsOverflowComponentProps) => { const [isPopoverOpen, , closePopover, togglePopover] = useBoolState(); const { navigateToApp } = useKibana().services.application; @@ -83,10 +86,20 @@ const RuleActionsOverflowComponent = ({ onClick={async () => { startTransaction({ name: SINGLE_RULE_ACTIONS.DUPLICATE }); closePopover(); + const modalDuplicationConfirmationResult = + await showBulkDuplicateExceptionsConfirmation(); + if (modalDuplicationConfirmationResult === null) { + return; + } const result = await executeBulkAction({ type: BulkActionType.duplicate, ids: [rule.id], + duplicatePayload: { + include_exceptions: + modalDuplicationConfirmationResult === DuplicateOptions.withExceptions, + }, }); + const createdRules = result?.attributes.results.created; if (createdRules?.length) { goToRuleEditPage(createdRules[0].id, navigateToApp); @@ -148,6 +161,7 @@ const RuleActionsOverflowComponent = ({ navigateToApp, onRuleDeletedCallback, rule, + showBulkDuplicateExceptionsConfirmation, startTransaction, userHasPermissions, downloadExportedRules, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts index 9a13833e08437..6a658fe6e9dca 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_exceptions/api/create_rule_exceptions/route.ts @@ -95,56 +95,7 @@ export const createRuleExceptionsRoute = (router: SecuritySolutionPluginRouter) }); } - let createdItems; - - const ruleDefaultLists = rule.params.exceptionsList.filter( - (list) => list.type === ExceptionListTypeEnum.RULE_DEFAULT - ); - - // This should hopefully never happen, but could if we forget to add such a check to one - // of our routes allowing the user to update the rule to have more than one default list added - checkDefaultRuleExceptionListReferences({ exceptionLists: rule.params.exceptionsList }); - - const [ruleDefaultList] = ruleDefaultLists; - - if (ruleDefaultList != null) { - // check that list does indeed exist - const exceptionListAssociatedToRule = await listsClient?.getExceptionList({ - id: ruleDefaultList.id, - listId: ruleDefaultList.list_id, - namespaceType: ruleDefaultList.namespace_type, - }); - - // if list does exist, just need to create the items - if (exceptionListAssociatedToRule != null) { - createdItems = await createExceptionListItems({ - items, - defaultList: exceptionListAssociatedToRule, - listsClient, - }); - } else { - // This means that there was missed cleanup when this rule exception list was - // deleted and it remained referenced on the rule. Let's remove it from the rule, - // and update the rule's exceptions lists to include newly created default list. - const defaultList = await createAndAssociateDefaultExceptionList({ - rule, - rulesClient, - listsClient, - removeOldAssociation: true, - }); - - createdItems = await createExceptionListItems({ items, defaultList, listsClient }); - } - } else { - const defaultList = await createAndAssociateDefaultExceptionList({ - rule, - rulesClient, - listsClient, - removeOldAssociation: false, - }); - - createdItems = await createExceptionListItems({ items, defaultList, listsClient }); - } + const createdItems = await createRuleExceptions({ items, rule, listsClient, rulesClient }); const [validated, errors] = validate(createdItems, t.array(exceptionListItemSchema)); if (errors != null) { @@ -163,6 +114,67 @@ export const createRuleExceptionsRoute = (router: SecuritySolutionPluginRouter) ); }; +export const createRuleExceptions = async ({ + items, + rule, + listsClient, + rulesClient, +}: { + items: CreateRuleExceptionListItemSchemaDecoded[]; + listsClient: ExceptionListClient | null; + rulesClient: RulesClient; + rule: SanitizedRule; +}) => { + const ruleDefaultLists = rule.params.exceptionsList.filter( + (list) => list.type === ExceptionListTypeEnum.RULE_DEFAULT + ); + + // This should hopefully never happen, but could if we forget to add such a check to one + // of our routes allowing the user to update the rule to have more than one default list added + checkDefaultRuleExceptionListReferences({ exceptionLists: rule.params.exceptionsList }); + + const [ruleDefaultList] = ruleDefaultLists; + + if (ruleDefaultList != null) { + // check that list does indeed exist + const exceptionListAssociatedToRule = await listsClient?.getExceptionList({ + id: ruleDefaultList.id, + listId: ruleDefaultList.list_id, + namespaceType: ruleDefaultList.namespace_type, + }); + + // if list does exist, just need to create the items + if (exceptionListAssociatedToRule != null) { + return createExceptionListItems({ + items, + defaultList: exceptionListAssociatedToRule, + listsClient, + }); + } else { + // This means that there was missed cleanup when this rule exception list was + // deleted and it remained referenced on the rule. Let's remove it from the rule, + // and update the rule's exceptions lists to include newly created default list. + const defaultList = await createAndAssociateDefaultExceptionList({ + rule, + rulesClient, + listsClient, + removeOldAssociation: true, + }); + + return createExceptionListItems({ items, defaultList, listsClient }); + } + } else { + const defaultList = await createAndAssociateDefaultExceptionList({ + rule, + rulesClient, + listsClient, + removeOldAssociation: false, + }); + + return createExceptionListItems({ items, defaultList, listsClient }); + } +}; + export const createExceptionListItems = async ({ items, defaultList, @@ -191,17 +203,15 @@ export const createExceptionListItems = async ({ ); }; -export const createAndAssociateDefaultExceptionList = async ({ +export const createExceptionList = async ({ rule, listsClient, - rulesClient, - removeOldAssociation, }: { rule: SanitizedRule; listsClient: ExceptionListClient | null; - rulesClient: RulesClient; - removeOldAssociation: boolean; -}): Promise => { +}): Promise => { + if (!listsClient) return null; + const exceptionList: CreateExceptionListSchema = { description: `Exception list containing exceptions for rule with id: ${rule.id}`, meta: undefined, @@ -233,7 +243,7 @@ export const createAndAssociateDefaultExceptionList = async ({ } = validated; // create the default rule list - const exceptionListAssociatedToRule = await listsClient?.createExceptionList({ + return listsClient.createExceptionList({ description, immutable: false, listId, @@ -244,8 +254,22 @@ export const createAndAssociateDefaultExceptionList = async ({ type, version, }); +}; + +export const createAndAssociateDefaultExceptionList = async ({ + rule, + listsClient, + rulesClient, + removeOldAssociation, +}: { + rule: SanitizedRule; + listsClient: ExceptionListClient | null; + rulesClient: RulesClient; + removeOldAssociation: boolean; +}): Promise => { + const exceptionListToAssociate = await createExceptionList({ rule, listsClient }); - if (exceptionListAssociatedToRule == null) { + if (exceptionListToAssociate == null) { throw Error(`An error occurred creating rule default exception list`); } @@ -265,14 +289,14 @@ export const createAndAssociateDefaultExceptionList = async ({ exceptions_list: [ ...ruleExceptionLists, { - id: exceptionListAssociatedToRule.id, - list_id: exceptionListAssociatedToRule.list_id, - type: exceptionListAssociatedToRule.type, - namespace_type: exceptionListAssociatedToRule.namespace_type, + id: exceptionListToAssociate.id, + list_id: exceptionListToAssociate.list_id, + type: exceptionListToAssociate.type, + namespace_type: exceptionListToAssociate.namespace_type, }, ], }, }); - return exceptionListAssociatedToRule; + return exceptionListToAssociate; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index da19964e5b7ec..3e884113f2c84 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -35,6 +35,7 @@ import { initPromisePool } from '../../../../../../utils/promise_pool'; import { buildMlAuthz } from '../../../../../machine_learning/authz'; import { deleteRules } from '../../../logic/crud/delete_rules'; import { duplicateRule } from '../../../logic/actions/duplicate_rule'; +import { duplicateExceptions } from '../../../logic/actions/duplicate_exceptions'; import { findRules } from '../../../logic/search/find_rules'; import { readRules } from '../../../logic/crud/read_rules'; import { getExportByObjectIds } from '../../../logic/export/get_export_by_object_ids'; @@ -497,18 +498,46 @@ export const performBulkActionRoute = ( if (isDryRun) { return rule; } - const migratedRule = await migrateRuleActions({ rulesClient, savedObjectsClient, rule, }); + let shouldDuplicateExceptions = true; + if (body.duplicate !== undefined) { + shouldDuplicateExceptions = body.duplicate.include_exceptions; + } + + const duplicateRuleToCreate = await duplicateRule({ + rule: migratedRule, + }); const createdRule = await rulesClient.create({ - data: duplicateRule(migratedRule), + data: duplicateRuleToCreate, + }); + + // we try to create exceptions after rule created, and then update rule + const exceptions = shouldDuplicateExceptions + ? await duplicateExceptions({ + ruleId: rule.params.ruleId, + exceptionLists: rule.params.exceptionsList, + exceptionsClient, + }) + : []; + + const updatedRule = await rulesClient.update({ + id: createdRule.id, + data: { + ...duplicateRuleToCreate, + params: { + ...duplicateRuleToCreate.params, + exceptionsList: exceptions, + }, + }, }); - return createdRule; + // TODO: figureout why types can't return just updatedRule + return { ...createdRule, ...updatedRule }; }, abortSignal: abortController.signal, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_exceptions.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_exceptions.ts new file mode 100644 index 0000000000000..496a91ba55963 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_exceptions.ts @@ -0,0 +1,62 @@ +/* + * 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 type { ExceptionListClient } from '@kbn/lists-plugin/server'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; + +import type { RuleParams } from '../../../rule_schema'; + +interface DuplicateExceptionsParams { + ruleId: RuleParams['ruleId']; + exceptionLists: RuleParams['exceptionsList']; + exceptionsClient: ExceptionListClient | undefined; +} + +export const duplicateExceptions = async ({ + ruleId, + exceptionLists, + exceptionsClient, +}: DuplicateExceptionsParams): Promise => { + if (exceptionLists == null) { + return []; + } + + // Sort the shared lists and the rule_default lists. + // Only a single rule_default list should exist per rule. + const ruleDefaultList = exceptionLists.find( + (list) => list.type === ExceptionListTypeEnum.RULE_DEFAULT + ); + const sharedLists = exceptionLists.filter( + (list) => list.type !== ExceptionListTypeEnum.RULE_DEFAULT + ); + + // For rule_default list (exceptions that live only on a single rule), we need + // to create a new rule_default list to assign to duplicated rule + if (ruleDefaultList != null && exceptionsClient != null) { + const ruleDefaultExceptionList = await exceptionsClient.duplicateExceptionListAndItems({ + listId: ruleDefaultList.list_id, + namespaceType: ruleDefaultList.namespace_type, + }); + + if (ruleDefaultExceptionList == null) { + throw new Error(`Unable to duplicate rule default exception items for rule_id: ${ruleId}`); + } + + return [ + ...sharedLists, + { + id: ruleDefaultExceptionList.id, + list_id: ruleDefaultExceptionList.list_id, + namespace_type: ruleDefaultExceptionList.namespace_type, + type: ruleDefaultExceptionList.type, + }, + ]; + } + + // If no rule_default list exists, we can just return + return [...sharedLists]; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.test.ts index 8637236f654d2..703e0f9ec70ab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.test.ts @@ -90,9 +90,11 @@ describe('duplicateRule', () => { jest.clearAllMocks(); }); - it('returns an object with fields copied from a given rule', () => { + it('returns an object with fields copied from a given rule', async () => { const rule = createTestRule(); - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual({ name: expect.anything(), // covered in a separate test @@ -111,10 +113,12 @@ describe('duplicateRule', () => { }); }); - it('appends [Duplicate] to the name', () => { + it('appends [Duplicate] to the name', async () => { const rule = createTestRule(); rule.name = 'PowerShell Keylogging Script'; - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ @@ -123,9 +127,11 @@ describe('duplicateRule', () => { ); }); - it('generates a new ruleId', () => { + it('generates a new ruleId', async () => { const rule = createTestRule(); - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ @@ -136,10 +142,12 @@ describe('duplicateRule', () => { ); }); - it('makes sure the duplicated rule is disabled', () => { + it('makes sure the duplicated rule is disabled', async () => { const rule = createTestRule(); rule.enabled = true; - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ @@ -155,9 +163,11 @@ describe('duplicateRule', () => { return rule; }; - it('transforms it to a custom (mutable) rule', () => { + it('transforms it to a custom (mutable) rule', async () => { const rule = createPrebuiltRule(); - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ @@ -168,7 +178,7 @@ describe('duplicateRule', () => { ); }); - it('resets related integrations to an empty array', () => { + it('resets related integrations to an empty array', async () => { const rule = createPrebuiltRule(); rule.params.relatedIntegrations = [ { @@ -178,7 +188,9 @@ describe('duplicateRule', () => { }, ]; - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ @@ -189,7 +201,7 @@ describe('duplicateRule', () => { ); }); - it('resets required fields to an empty array', () => { + it('resets required fields to an empty array', async () => { const rule = createPrebuiltRule(); rule.params.requiredFields = [ { @@ -199,7 +211,9 @@ describe('duplicateRule', () => { }, ]; - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ @@ -210,10 +224,12 @@ describe('duplicateRule', () => { ); }); - it('resets setup guide to an empty string', () => { + it('resets setup guide to an empty string', async () => { const rule = createPrebuiltRule(); rule.params.setup = `## Config\n\nThe 'Audit Detailed File Share' audit policy must be configured...`; - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ @@ -232,9 +248,11 @@ describe('duplicateRule', () => { return rule; }; - it('keeps it custom', () => { + it('keeps it custom', async () => { const rule = createCustomRule(); - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ @@ -245,7 +263,7 @@ describe('duplicateRule', () => { ); }); - it('copies related integrations as is', () => { + it('copies related integrations as is', async () => { const rule = createCustomRule(); rule.params.relatedIntegrations = [ { @@ -255,7 +273,9 @@ describe('duplicateRule', () => { }, ]; - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ @@ -266,7 +286,7 @@ describe('duplicateRule', () => { ); }); - it('copies required fields as is', () => { + it('copies required fields as is', async () => { const rule = createCustomRule(); rule.params.requiredFields = [ { @@ -276,7 +296,9 @@ describe('duplicateRule', () => { }, ]; - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ @@ -287,10 +309,12 @@ describe('duplicateRule', () => { ); }); - it('copies setup guide as is', () => { + it('copies setup guide as is', async () => { const rule = createCustomRule(); rule.params.setup = `## Config\n\nThe 'Audit Detailed File Share' audit policy must be configured...`; - const result = duplicateRule(rule); + const result = await duplicateRule({ + rule, + }); expect(result).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.ts index 5f15a2ed81d1f..f5ee4fc8ae35d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.ts @@ -9,7 +9,6 @@ import uuid from 'uuid'; import { i18n } from '@kbn/i18n'; import { ruleTypeMappings } from '@kbn/securitysolution-rules'; import type { SanitizedRule } from '@kbn/alerting-plugin/common'; - import { SERVER_APP_ID } from '../../../../../../common/constants'; import type { InternalRuleCreate, RuleParams } from '../../../rule_schema'; @@ -20,7 +19,11 @@ const DUPLICATE_TITLE = i18n.translate( } ); -export const duplicateRule = (rule: SanitizedRule): InternalRuleCreate => { +interface DuplicateRuleParams { + rule: SanitizedRule; +} + +export const duplicateRule = async ({ rule }: DuplicateRuleParams): Promise => { // Generate a new static ruleId const ruleId = uuid.v4(); @@ -43,6 +46,7 @@ export const duplicateRule = (rule: SanitizedRule): InternalRuleCrea relatedIntegrations, requiredFields, setup, + exceptionsList: [], }, schedule: rule.schedule, enabled: false, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts index 82cb42c0039c3..5a9777e7f2e79 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts @@ -310,7 +310,11 @@ export default ({ getService }: FtrProviderContext): void => { await createRule(supertest, log, ruleToDuplicate); const { body } = await postBulkAction() - .send({ query: '', action: BulkActionType.duplicate }) + .send({ + query: '', + action: BulkActionType.duplicate, + duplicate: { include_exceptions: false }, + }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 }); @@ -352,7 +356,11 @@ export default ({ getService }: FtrProviderContext): void => { ); const { body } = await postBulkAction() - .send({ query: '', action: BulkActionType.duplicate }) + .send({ + query: '', + action: BulkActionType.duplicate, + duplicate: { include_exceptions: false }, + }) .expect(200); expect(body.attributes.summary).to.eql({ failed: 0, succeeded: 1, total: 1 }); From 3789ba31b92579cb677e36dcd84278ce8906279e Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 15 Nov 2022 19:52:12 +0100 Subject: [PATCH 09/13] [ML] Feature flag to disable Change Point Detection UI (#145285) ## Summary Feature flag to hide [Change Point Detection ](https://github.com/elastic/kibana/pull/144093) --- x-pack/plugins/aiops/common/index.ts | 2 ++ .../components/ml_page/side_nav.tsx | 26 +++++++++++-------- .../routes/aiops/change_point_detection.tsx | 4 +-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/aiops/common/index.ts b/x-pack/plugins/aiops/common/index.ts index 0f4835d67ecc7..0726447532aa2 100755 --- a/x-pack/plugins/aiops/common/index.ts +++ b/x-pack/plugins/aiops/common/index.ts @@ -20,3 +20,5 @@ export const PLUGIN_NAME = 'AIOps'; * "Explain log rate spikes UI" during development until the first release. */ export const AIOPS_ENABLED = true; + +export const CHANGE_POINT_DETECTION_ENABLED = false; diff --git a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx index 816aec2eec81a..2d755d3cb1d54 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { EuiSideNavItemType } from '@elastic/eui'; import React, { ReactNode, useCallback, useMemo } from 'react'; -import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; +import { AIOPS_ENABLED, CHANGE_POINT_DETECTION_ENABLED } from '@kbn/aiops-plugin/common'; import { NotificationsIndicator } from './notifications_indicator'; import type { MlLocatorParams } from '../../../../common/types/locator'; import { useUrlState } from '../../util/url_state'; @@ -266,16 +266,20 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { testSubj: 'mlMainTab logCategorization', relatedRouteIds: ['log_categorization'], }, - { - id: 'changePointDetection', - pathId: ML_PAGES.AIOPS_CHANGE_POINT_DETECTION_INDEX_SELECT, - name: i18n.translate('xpack.ml.navMenu.changePointDetectionLinkText', { - defaultMessage: 'Change Point Detection', - }), - disabled: disableLinks, - testSubj: 'mlMainTab changePointDetection', - relatedRouteIds: ['change_point_detection'], - }, + ...(CHANGE_POINT_DETECTION_ENABLED + ? [ + { + id: 'changePointDetection', + pathId: ML_PAGES.AIOPS_CHANGE_POINT_DETECTION_INDEX_SELECT, + name: i18n.translate('xpack.ml.navMenu.changePointDetectionLinkText', { + defaultMessage: 'Change Point Detection', + }), + disabled: disableLinks, + testSubj: 'mlMainTab changePointDetection', + relatedRouteIds: ['change_point_detection'], + }, + ] + : []), ], }); } diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx index 06e7fc25617de..47be592377825 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; +import { CHANGE_POINT_DETECTION_ENABLED } from '@kbn/aiops-plugin/common'; import { i18n } from '@kbn/i18n'; import React, { FC } from 'react'; import { parse } from 'query-string'; @@ -37,7 +37,7 @@ export const changePointDetectionRouteFactory = ( }), }, ], - disabled: !AIOPS_ENABLED, + disabled: !CHANGE_POINT_DETECTION_ENABLED, }); const PageWrapper: FC = ({ location, deps }) => { From a2647ab67cd65d66cb0cb3857c483e261e9e605f Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Tue, 15 Nov 2022 11:08:41 -0800 Subject: [PATCH 10/13] [Security Solution][Alerts] Alert suppression per rule execution (#142686) ## Summary Addresses https://github.com/elastic/kibana/issues/130699 This PR implements alert throttling per rule execution for query and saved query rules. The implementation is very similar in concept to threshold rules. We allow users to pick one or more fields to group source documents by and use a composite aggregation to collect documents bucketed by those fields. We create 1 alert for each bucket based on the first document in the bucket and add metadata to the alert that represents how to retrieve the rest of the documents in the bucket. The metadata fields are: - `kibana.alert.suppression.terms`: `{field: string; value: Array}` An array of objects, each object represents one of the terms used to group these alerts - `kibana.alert.suppression.start`: `Date` The timestamp of the first document in the bucket - `kibana.alert.suppression.end`: `Date` The timestamp of the last document in the bucket - `kibana.alert.suppression.docs_count`: `number` The number of suppressed alerts There is one new rule parameter, currently implemented at the solution level, to enable this feature: `alertSuppression.groupBy`: `string[]`. Similar to threshold rules, the throttled query rules keep track of created alerts in the rule state in order to filter out duplicate documents in subsequent rule executions. When a throttled alert is created, we store the bucket information including field names, values, and end date in the rule state. Subsequent rule executions convert this state into a filter that excludes documents that have already been covered by existing alerts. This is necessary because consecutive rule executions will typically query overlapping time ranges. ## Screenshots ### Rule Create/Edit With License
![image](https://user-images.githubusercontent.com/55718608/201762013-c973b121-e85a-4163-a645-24beaa738add.png)
### Rule Details With License
![image](https://user-images.githubusercontent.com/55718608/201970156-6e64fe01-e7b2-43c0-a740-45f72ad21863.png)
### Rule Create, or Rule Edit of a rule without existing suppression configuration, Without License
![image](https://user-images.githubusercontent.com/55718608/201763392-20364d77-809b-46a0-b3c0-9ca7fe04f636.png)
### Editing a rule that has existing suppression configuration, but without the correct license, still allows changing the configuration (to allow removing the params)
![image](https://user-images.githubusercontent.com/55718608/201763671-afb2e7b8-6c8f-4a5e-8947-99ad21dd92f9.png)
### Rule Details Without License
![image](https://user-images.githubusercontent.com/55718608/201970472-8e69267d-7c53-4172-9b45-b8b46ebd67bc.png)
### Alerts table
![image](https://user-images.githubusercontent.com/55718608/201968736-e0165387-bb08-45ce-a92f-5e2b428c7426.png)
### Known issues - The layers icon in the rule name for suppressed alerts does not show up in the rule preview table Co-authored-by: Madi Caldwell Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/technical_field_names.ts | 19 + .../technical_rule_field_map.test.ts | 25 + .../field_maps/technical_rule_field_map.ts | 25 + .../detection_engine/rule_schema/index.ts | 1 + .../model/rule_response_schema.mock.ts | 2 + .../rule_schema/model/rule_schemas.ts | 3 + .../specific_attributes/query_attributes.ts | 29 + .../schemas/alerts/8.6.0/index.ts | 35 + .../detection_engine/schemas/alerts/index.ts | 7 +- .../pages/rule_creation/helpers.ts | 3 + .../rule_management/logic/types.ts | 2 + .../components/rules_table/__mocks__/mock.ts | 4 + .../components/alerts_table/actions.tsx | 179 ++++- .../alerts_table/default_config.tsx | 1 + .../rules/description_step/helpers.tsx | 47 ++ .../rules/description_step/index.test.tsx | 54 +- .../rules/description_step/index.tsx | 15 +- .../rules/description_step/translations.tsx | 8 + .../rules/group_by_fields/index.tsx | 55 ++ .../rules/group_by_fields/translations.ts | 22 + .../rules/step_about_rule/index.test.tsx | 1 + .../rules/step_define_rule/index.tsx | 17 + .../rules/step_define_rule/schema.tsx | 40 ++ .../render_cell_value.tsx | 30 +- .../translations.ts | 14 + .../detection_engine/rules/helpers.test.tsx | 3 + .../pages/detection_engine/rules/helpers.tsx | 1 + .../pages/detection_engine/rules/types.ts | 1 + .../pages/detection_engine/rules/utils.ts | 1 + .../routes/__mocks__/utils.ts | 1 + .../schedule_notification_actions.test.ts | 1 + ...dule_throttle_notification_actions.test.ts | 1 + .../logic/actions/duplicate_rule.test.ts | 1 + .../export/get_export_by_object_ids.test.ts | 1 + .../normalization/rule_converters.ts | 7 + .../rule_management/utils/utils.ts | 25 +- .../rule_management/utils/validate.test.ts | 1 + .../rule_preview/api/preview_rules/route.ts | 20 +- .../rule_schema/model/rule_schemas.mock.ts | 2 + .../rule_schema/model/rule_schemas.ts | 10 + .../create_security_rule_type_wrapper.ts | 1 + .../factories/utils/wrap_suppressed_alerts.ts | 87 +++ .../lib/detection_engine/rule_types/index.ts | 1 - .../new_terms/create_new_terms_alert_type.ts | 6 +- .../query/create_query_alert_type.test.ts | 5 + .../query/create_query_alert_type.ts | 63 +- .../create_saved_query_alert_type.ts | 108 ---- .../lib/detection_engine/rule_types/types.ts | 8 +- .../signals/__mocks__/es_results.ts | 1 + ...ld_group_by_field_aggregation.test.ts.snap | 45 ++ .../group_and_bulk_create.test.ts.snap | 62 ++ .../build_group_by_field_aggregation.test.ts | 22 + .../build_group_by_field_aggregation.ts | 56 ++ .../group_and_bulk_create.test.ts | 58 ++ .../group_and_bulk_create.ts | 218 +++++++ .../signals/build_events_query.ts | 10 +- .../signals/executors/query.ts | 99 ++- .../signals/single_search_after.ts | 3 + .../lib/detection_engine/signals/types.ts | 31 +- .../security_solution/server/plugin.ts | 15 +- .../timeline/factory/helpers/constants.ts | 1 + .../group1/create_new_terms.ts | 2 +- .../rule_execution_logic/query.ts | 237 +++++++ .../utils/get_preview_alerts.ts | 3 + .../utils/get_simple_rule_output.ts | 1 + .../security_solution/suppression/data.json | 612 ++++++++++++++++++ .../suppression/mappings.json | 50 ++ 67 files changed, 2276 insertions(+), 243 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/specific_attributes/query_attributes.ts create mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.6.0/index.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/group_by_fields/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/group_by_fields/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/translations.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/wrap_suppressed_alerts.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/saved_query/create_saved_query_alert_type.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/__snapshots__/build_group_by_field_aggregation.test.ts.snap create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/__snapshots__/group_and_bulk_create.test.ts.snap create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/build_group_by_field_aggregation.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/build_group_by_field_aggregation.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/group_and_bulk_create.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/group_and_bulk_create.ts create mode 100644 x-pack/test/functional/es_archives/security_solution/suppression/data.json create mode 100644 x-pack/test/functional/es_archives/security_solution/suppression/mappings.json diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index ae37273c8aefb..6b51906cca1ef 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -43,6 +43,13 @@ const ALERT_UUID = `${ALERT_NAMESPACE}.uuid` as const; const ALERT_WORKFLOW_REASON = `${ALERT_NAMESPACE}.workflow_reason` as const; const ALERT_WORKFLOW_STATUS = `${ALERT_NAMESPACE}.workflow_status` as const; const ALERT_WORKFLOW_USER = `${ALERT_NAMESPACE}.workflow_user` as const; +const ALERT_SUPPRESSION_META = `${ALERT_NAMESPACE}.suppression` as const; +const ALERT_SUPPRESSION_TERMS = `${ALERT_SUPPRESSION_META}.terms` as const; +const ALERT_SUPPRESSION_FIELD = `${ALERT_SUPPRESSION_TERMS}.field` as const; +const ALERT_SUPPRESSION_VALUE = `${ALERT_SUPPRESSION_TERMS}.value` as const; +const ALERT_SUPPRESSION_START = `${ALERT_SUPPRESSION_META}.start` as const; +const ALERT_SUPPRESSION_END = `${ALERT_SUPPRESSION_META}.end` as const; +const ALERT_SUPPRESSION_DOCS_COUNT = `${ALERT_SUPPRESSION_META}.docs_count` as const; // Fields pertaining to the rule associated with the alert const ALERT_RULE_AUTHOR = `${ALERT_RULE_NAMESPACE}.author` as const; @@ -167,6 +174,12 @@ const fields = { ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID, ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME, ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE, + ALERT_SUPPRESSION_TERMS, + ALERT_SUPPRESSION_FIELD, + ALERT_SUPPRESSION_VALUE, + ALERT_SUPPRESSION_START, + ALERT_SUPPRESSION_END, + ALERT_SUPPRESSION_DOCS_COUNT, SPACE_IDS, VERSION, }; @@ -236,6 +249,12 @@ export { ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID, ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME, ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE, + ALERT_SUPPRESSION_TERMS, + ALERT_SUPPRESSION_FIELD, + ALERT_SUPPRESSION_VALUE, + ALERT_SUPPRESSION_START, + ALERT_SUPPRESSION_END, + ALERT_SUPPRESSION_DOCS_COUNT, TAGS, TIMESTAMP, SPACE_IDS, diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts index e01ab0105a5d5..e546f339d2b88 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts @@ -196,6 +196,31 @@ it('matches snapshot', () => { "required": true, "type": "keyword", }, + "kibana.alert.suppression.docs_count": Object { + "array": false, + "required": false, + "type": "long", + }, + "kibana.alert.suppression.end": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.suppression.start": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.suppression.terms.field": Object { + "array": true, + "required": false, + "type": "keyword", + }, + "kibana.alert.suppression.terms.value": Object { + "array": true, + "required": false, + "type": "keyword", + }, "kibana.alert.system_status": Object { "array": false, "required": false, diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts index 82994950dfd04..aeebe987e20de 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts @@ -189,6 +189,31 @@ export const technicalRuleFieldMap = { array: false, required: false, }, + [Fields.ALERT_SUPPRESSION_FIELD]: { + type: 'keyword', + array: true, + required: false, + }, + [Fields.ALERT_SUPPRESSION_VALUE]: { + type: 'keyword', + array: true, + required: false, + }, + [Fields.ALERT_SUPPRESSION_START]: { + type: 'date', + array: false, + required: false, + }, + [Fields.ALERT_SUPPRESSION_END]: { + type: 'date', + array: false, + required: false, + }, + [Fields.ALERT_SUPPRESSION_DOCS_COUNT]: { + type: 'long', + array: false, + required: false, + }, } as const; export type TechnicalRuleFieldMap = typeof technicalRuleFieldMap; diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_schema/index.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_schema/index.ts index cf1266b1b9a71..b63c27f71f79a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_schema/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_schema/index.ts @@ -14,6 +14,7 @@ export * from './model/common_attributes/timeline_template'; export * from './model/specific_attributes/eql_attributes'; export * from './model/specific_attributes/new_terms_attributes'; +export * from './model/specific_attributes/query_attributes'; export * from './model/specific_attributes/threshold_attributes'; export * from './model/rule_schemas'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/rule_response_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/rule_response_schema.mock.ts index 0a99da6b4f6f3..86018ff1a7b88 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/rule_response_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/rule_response_schema.mock.ts @@ -75,6 +75,7 @@ export const getRulesSchemaMock = (anchorDate: string = ANCHOR_DATE): QueryRule filters: undefined, saved_id: undefined, response_actions: undefined, + alert_suppression: undefined, }); export const getSavedQuerySchemaMock = (anchorDate: string = ANCHOR_DATE): SavedQueryRule => ({ @@ -87,6 +88,7 @@ export const getSavedQuerySchemaMock = (anchorDate: string = ANCHOR_DATE): Saved data_view_id: undefined, filters: undefined, response_actions: undefined, + alert_suppression: undefined, }); export const getRulesMlSchemaMock = (anchorDate: string = ANCHOR_DATE): MachineLearningRule => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/rule_schemas.ts index 9985ef4102736..5d35811368b39 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/rule_schemas.ts @@ -85,6 +85,7 @@ import { } from './specific_attributes/eql_attributes'; import { Threshold } from './specific_attributes/threshold_attributes'; import { HistoryWindowStart, NewTermsFields } from './specific_attributes/new_terms_attributes'; +import { AlertSuppression } from './specific_attributes/query_attributes'; import { buildRuleSchemas } from './build_rule_schemas'; @@ -302,6 +303,7 @@ const querySchema = buildRuleSchemas({ filters: RuleFilterArray, saved_id, response_actions: ResponseActionArray, + alert_suppression: AlertSuppression, }, defaultable: { query: RuleQuery, @@ -340,6 +342,7 @@ const savedQuerySchema = buildRuleSchemas({ query: RuleQuery, filters: RuleFilterArray, response_actions: ResponseActionArray, + alert_suppression: AlertSuppression, }, defaultable: { language: t.keyof({ kuery: null, lucene: null }), diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/specific_attributes/query_attributes.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/specific_attributes/query_attributes.ts new file mode 100644 index 0000000000000..4edb5c6885af1 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/specific_attributes/query_attributes.ts @@ -0,0 +1,29 @@ +/* + * 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 * as t from 'io-ts'; +import { LimitedSizeArray } from '@kbn/securitysolution-io-ts-types'; + +export const AlertSuppressionGroupBy = LimitedSizeArray({ + codec: t.string, + minSize: 1, + maxSize: 3, +}); + +/** + * Schema for fields relating to alert suppression, which enables limiting the number of alerts per entity. + * e.g. group_by: ['host.name'] would create only one alert per value of host.name. The created alert + * contains metadata about how many other candidate alerts with the same host.name value were suppressed. + */ +export type AlertSuppression = t.TypeOf; +export const AlertSuppression = t.exact( + t.type({ + group_by: AlertSuppressionGroupBy, + }) +); + +export const minimumLicenseForSuppression = 'platinum'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.6.0/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.6.0/index.ts new file mode 100644 index 0000000000000..3bf66b5e31ec6 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.6.0/index.ts @@ -0,0 +1,35 @@ +/* + * 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 type { AlertWithCommonFields800 } from '@kbn/rule-registry-plugin/common/schemas/8.0.0'; +import type { + ALERT_SUPPRESSION_TERMS, + ALERT_SUPPRESSION_START, + ALERT_SUPPRESSION_END, + ALERT_SUPPRESSION_DOCS_COUNT, +} from '@kbn/rule-data-utils'; + +import type { BaseFields840, DetectionAlert840 } from '../8.4.0'; + +/* DO NOT MODIFY THIS SCHEMA TO ADD NEW FIELDS. These types represent the alerts that shipped in 8.6.0. +Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.6.0. +If you are adding new fields for a new release of Kibana, create a new sibling folder to this one +for the version to be released and add the field(s) to the schema in that folder. +Then, update `../index.ts` to import from the new folder that has the latest schemas, add the +new schemas to the union of all alert schemas, and re-export the new schemas as the `*Latest` schemas. +*/ + +export interface SuppressionFields860 extends BaseFields840 { + [ALERT_SUPPRESSION_TERMS]: Array<{ field: string; value: string | number | null }>; + [ALERT_SUPPRESSION_START]: Date; + [ALERT_SUPPRESSION_END]: Date; + [ALERT_SUPPRESSION_DOCS_COUNT]: number; +} + +export type SuppressionAlert860 = AlertWithCommonFields800; + +export type DetectionAlert860 = DetectionAlert840 | SuppressionAlert860; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts index a1a0a9079234a..93436ffa52d6b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts @@ -17,16 +17,19 @@ import type { NewTermsFields840, } from './8.4.0'; +import type { DetectionAlert860, SuppressionFields860 } from './8.6.0'; + // When new Alert schemas are created for new Kibana versions, add the DetectionAlert type from the new version // here, e.g. `export type DetectionAlert = DetectionAlert800 | DetectionAlert820` if a new schema is created in 8.2.0 -export type DetectionAlert = DetectionAlert800 | DetectionAlert840; +export type DetectionAlert = DetectionAlert800 | DetectionAlert840 | DetectionAlert860; export type { Ancestor840 as AncestorLatest, BaseFields840 as BaseFieldsLatest, - DetectionAlert840 as DetectionAlertLatest, + DetectionAlert860 as DetectionAlertLatest, WrappedFields840 as WrappedFieldsLatest, EqlBuildingBlockFields840 as EqlBuildingBlockFieldsLatest, EqlShellFields840 as EqlShellFieldsLatest, NewTermsFields840 as NewTermsFieldsLatest, + SuppressionFields860 as SuppressionFieldsLatest, }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts index 6686239e053f9..d01bc59c0bbe3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_creation/helpers.ts @@ -435,6 +435,9 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep history_window_start: `now-${ruleFields.historyWindowSize}`, } : { + ...(ruleFields.groupByFields.length > 0 + ? { alert_suppression: { group_by: ruleFields.groupByFields } } + : {}), index: ruleFields.index, filters: ruleFields.queryBar?.filters, language: ruleFields.queryBar?.query?.language, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index fa2b7f16ce9f7..d598969acd155 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -30,6 +30,7 @@ import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; import { RuleExecutionSummary } from '../../../../common/detection_engine/rule_monitoring'; import { + AlertSuppression, AlertsIndex, BuildingBlockType, DataViewId, @@ -191,6 +192,7 @@ export const RuleSchema = t.intersection([ uuid: t.string, version: RuleVersion, execution_summary: RuleExecutionSummary, + alert_suppression: AlertSuppression, }), ]); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts index ac22095820255..f28d6b53821e2 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/__mocks__/mock.ts @@ -167,6 +167,9 @@ export const mockRuleWithEverything = (id: string): Rule => ({ timestamp_override_fallback_disabled: false, note: '# this is some markdown documentation', version: 1, + alert_suppression: { + group_by: ['host.name'], + }, new_terms_fields: ['host.name'], history_window_start: 'now-7d', }); @@ -226,6 +229,7 @@ export const mockDefineStepRule = (): DefineStepRule => ({ newTermsFields: ['host.ip'], historyWindowSize: '7d', shouldLoadQueryDynamically: false, + groupByFields: [], }); export const mockScheduleStepRule = (): ScheduleStepRule => ({ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index 7d69b8ee52122..aaa2a7ace106a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -22,6 +22,10 @@ import { ALERT_RULE_TYPE, ALERT_RULE_NOTE, ALERT_RULE_PARAMETERS, + ALERT_SUPPRESSION_START, + ALERT_SUPPRESSION_END, + ALERT_SUPPRESSION_DOCS_COUNT, + ALERT_SUPPRESSION_TERMS, } from '@kbn/rule-data-utils'; import type { TGridModel } from '@kbn/timelines-plugin/public'; @@ -283,6 +287,10 @@ export const isNewTermsAlert = (ecsData: Ecs): boolean => { ); }; +const isSuppressedAlert = (ecsData: Ecs): boolean => { + return getField(ecsData, ALERT_SUPPRESSION_DOCS_COUNT) != null; +}; + export const buildAlertsKqlFilter = ( key: '_id' | 'signal.group.id' | 'kibana.alert.group.id', alertIds: string[], @@ -528,7 +536,7 @@ const createThresholdTimeline = async ( title: i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.createThresholdTimelineFailureTitle', { - defaultMessage: 'Failed to create theshold alert timeline', + defaultMessage: 'Failed to create threshold alert timeline', } ), }); @@ -695,6 +703,160 @@ const createNewTermsTimeline = async ( } }; +const getSuppressedAlertData = (ecsData: Ecs | Ecs[]) => { + const normalizedEcsData: Ecs = Array.isArray(ecsData) ? ecsData[0] : ecsData; + const from = getField(normalizedEcsData, ALERT_SUPPRESSION_START); + const to = getField(normalizedEcsData, ALERT_SUPPRESSION_END); + const terms: Array<{ field: string; value: string | number }> = getField( + normalizedEcsData, + ALERT_SUPPRESSION_TERMS + ); + const dataProviderPartials = terms.map((term) => { + const fieldId = term.field.replace('.', '-'); + return { + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-${fieldId}-${term.value}`, + name: fieldId, + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: term.field, + value: term.value, + operator: ':' as const, + }, + }; + }); + const dataProvider = { + ...dataProviderPartials[0], + and: dataProviderPartials.slice(1), + }; + return { + from, + to, + dataProviders: [dataProvider], + }; +}; + +const createSuppressedTimeline = async ( + ecsData: Ecs, + createTimeline: ({ from, timeline, to }: CreateTimelineProps) => void, + noteContent: string, + templateValues: { + filters?: Filter[]; + query?: string; + dataProviders?: DataProvider[]; + columns?: TGridModel['columns']; + }, + getExceptionFilter: GetExceptionFilter +) => { + try { + const alertResponse = await KibanaServices.get().http.fetch< + estypes.SearchResponse<{ '@timestamp': string; [key: string]: unknown }> + >(DETECTION_ENGINE_QUERY_SIGNALS_URL, { + method: 'POST', + body: JSON.stringify(buildAlertsQuery([ecsData._id])), + }); + const formattedAlertData = + alertResponse?.hits.hits.reduce((acc, { _id, _index, _source = {} }) => { + return [ + ...acc, + { + ...formatAlertToEcsSignal(_source), + _id, + _index, + timestamp: _source['@timestamp'], + }, + ]; + }, []) ?? []; + + const alertDoc = formattedAlertData[0]; + const params = getField(alertDoc, ALERT_RULE_PARAMETERS); + const filters: Filter[] = + (params as MightHaveFilters).filters ?? + (alertDoc.signal?.rule as MightHaveFilters)?.filters ?? + []; + // https://github.com/elastic/kibana/issues/126574 - if the provided filter has no `meta` field + // we expect an empty object to be inserted before calling `createTimeline` + const augmentedFilters = filters.map((filter) => { + return filter.meta != null ? filter : { ...filter, meta: {} }; + }); + const language = params.language ?? alertDoc.signal?.rule?.language ?? 'kuery'; + const query = params.query ?? alertDoc.signal?.rule?.query ?? ''; + const indexNames = getField(alertDoc, ALERT_RULE_INDICES) ?? alertDoc.signal?.rule?.index ?? []; + + const { from, to, dataProviders } = getSuppressedAlertData(alertDoc); + const exceptionsFilter = await getExceptionFilter(ecsData); + + const allFilters = (templateValues.filters ?? augmentedFilters).concat( + !exceptionsFilter ? [] : [exceptionsFilter] + ); + + return createTimeline({ + from, + notes: null, + timeline: { + ...timelineDefaults, + columns: templateValues.columns ?? timelineDefaults.columns, + description: `_id: ${alertDoc._id}`, + filters: allFilters, + dataProviders: templateValues.dataProviders ?? dataProviders, + id: TimelineId.active, + indexNames, + dateRange: { + start: from, + end: to, + }, + eventType: 'all', + kqlQuery: { + filterQuery: { + kuery: { + kind: language, + expression: templateValues.query ?? query, + }, + serializedQuery: templateValues.query ?? query, + }, + }, + }, + to, + ruleNote: noteContent, + }); + } catch (error) { + const { toasts } = KibanaServices.get().notifications; + toasts.addError(error, { + toastMessage: i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.createSuppressedTimelineFailure', + { + defaultMessage: 'Failed to create timeline for document _id: {id}', + values: { id: ecsData._id }, + } + ), + title: i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.createSuppressedTimelineFailureTitle', + { + defaultMessage: 'Failed to create suppressed alert timeline', + } + ), + }); + const from = DEFAULT_FROM_MOMENT.toISOString(); + const to = DEFAULT_TO_MOMENT.toISOString(); + return createTimeline({ + from, + notes: null, + timeline: { + ...timelineDefaults, + id: TimelineId.active, + indexNames: [], + dateRange: { + start: from, + end: to, + }, + eventType: 'all', + }, + to, + }); + } +}; + export const sendBulkEventsToTimelineAction = ( createTimeline: CreateTimeline, ecs: Ecs[], @@ -835,6 +997,19 @@ export const sendAlertToTimelineAction = async ({ }, getExceptionFilter ); + } else if (isSuppressedAlert(ecsData)) { + return createSuppressedTimeline( + ecsData, + createTimeline, + noteContent, + { + filters, + query, + dataProviders, + columns: timeline.columns, + }, + getExceptionFilter + ); } else { return createTimeline({ from, @@ -891,6 +1066,8 @@ export const sendAlertToTimelineAction = async ({ return createThresholdTimeline(ecsData, createTimeline, noteContent, {}, getExceptionFilter); } else if (isNewTermsAlert(ecsData)) { return createNewTermsTimeline(ecsData, createTimeline, noteContent, {}, getExceptionFilter); + } else if (isSuppressedAlert(ecsData)) { + return createSuppressedTimeline(ecsData, createTimeline, noteContent, {}, getExceptionFilter); } else { let { dataProviders, filters } = buildTimelineDataProviderOrFilter( [ecsData._id], diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index b35e1d9b0f89c..fcaad7017ca47 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -183,6 +183,7 @@ export const requiredFieldsForActions = [ 'kibana.alert.rule.to', 'kibana.alert.rule.uuid', 'kibana.alert.rule.type', + 'kibana.alert.suppression.docs_count', 'kibana.alert.original_event.kind', 'kibana.alert.original_event.module', // Endpoint exception fields diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx index cf9711c58f8cb..90405c022799d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx @@ -37,6 +37,7 @@ import type { RequiredFieldArray, Threshold, } from '../../../../../common/detection_engine/rule_schema'; +import { minimumLicenseForSuppression } from '../../../../../common/detection_engine/rule_schema'; import * as i18n from './translations'; import type { BuildQueryBarDescription, BuildThreatDescription, ListItems } from './types'; @@ -47,6 +48,7 @@ import type { } from '../../../pages/detection_engine/rules/types'; import { defaultToEmptyTag } from '../../../../common/components/empty_value'; import { ThreatEuiFlexGroup } from './threat_description'; +import type { LicenseService } from '../../../../../common/license'; const NoteDescriptionContainer = styled(EuiFlexItem)` height: 105px; @@ -512,3 +514,48 @@ export const buildRequiredFieldsDescription = ( }, ]; }; + +export const buildAlertSuppressionDescription = ( + label: string, + values: string[], + license: LicenseService +): ListItems[] => { + if (isEmpty(values)) { + return []; + } + const description = ( + + {values.map((val: string) => + isEmpty(val) ? null : ( + + + {val} + + + ) + )} + + ); + if (license.isAtLeast(minimumLicenseForSuppression)) { + return [ + { + title: label, + description, + }, + ]; + } else { + return [ + { + title: ( + <> + {label}  + + + + + ), + description, + }, + ]; + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx index aeba71acb6ab8..a2206c9c562e7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx @@ -29,6 +29,7 @@ import * as i18n from './translations'; import { schema } from '../step_about_rule/schema'; import type { ListItems } from './types'; import type { AboutStepRule } from '../../../pages/detection_engine/rules/types'; +import { createLicenseServiceMock } from '../../../../../common/license/mocks'; jest.mock('../../../../common/lib/kibana'); @@ -44,6 +45,7 @@ describe('description_step', () => { }; let mockFilterManager: FilterManager; let mockAboutStep: AboutStepRule; + const mockLicenseService = createLicenseServiceMock(); beforeEach(() => { setupMock.uiSettings.get.mockImplementation(uiSettingsMock(true)); @@ -253,7 +255,12 @@ describe('description_step', () => { describe('buildListItems', () => { test('returns expected ListItems array when given valid inputs', () => { - const result: ListItems[] = buildListItems(mockAboutStep, schema, mockFilterManager); + const result: ListItems[] = buildListItems( + mockAboutStep, + schema, + mockFilterManager, + mockLicenseService + ); expect(result.length).toEqual(11); }); @@ -265,7 +272,8 @@ describe('description_step', () => { 'tags', 'Tags label', mockAboutStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Tags label'); @@ -277,7 +285,8 @@ describe('description_step', () => { 'description', 'Description label', mockAboutStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Description label'); @@ -289,7 +298,8 @@ describe('description_step', () => { 'jibberjabber', 'JibberJabber label', mockAboutStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result.length).toEqual(0); @@ -311,7 +321,8 @@ describe('description_step', () => { 'queryBar', 'Query bar label', mockQueryBar, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual(<>{i18n.QUERY_LABEL}); @@ -327,7 +338,8 @@ describe('description_step', () => { 'threat', 'Threat label', mockAboutStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Threat label'); @@ -359,7 +371,8 @@ describe('description_step', () => { 'threat', 'Threat label', mockStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result.length).toEqual(0); @@ -378,7 +391,8 @@ describe('description_step', () => { 'threshold', 'Threshold label', mockThreshold, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Threshold label'); @@ -399,7 +413,8 @@ describe('description_step', () => { 'threshold', 'Threshold label', mockThreshold, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Threshold label'); @@ -416,7 +431,8 @@ describe('description_step', () => { 'references', 'Reference label', mockAboutStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Reference label'); @@ -430,7 +446,8 @@ describe('description_step', () => { 'falsePositives', 'False positives label', mockAboutStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('False positives label'); @@ -444,7 +461,8 @@ describe('description_step', () => { 'severity', 'Severity label', mockAboutStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Severity'); @@ -458,7 +476,8 @@ describe('description_step', () => { 'riskScore', 'Risk score label', mockAboutStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Risk score'); @@ -473,7 +492,8 @@ describe('description_step', () => { 'timeline', 'Timeline label', mockDefineStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Timeline label'); @@ -491,7 +511,8 @@ describe('description_step', () => { 'timeline', 'Timeline label', mockStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Timeline label'); @@ -505,7 +526,8 @@ describe('description_step', () => { 'note', 'Investigation guide', mockAboutStep, - mockFilterManager + mockFilterManager, + mockLicenseService ); expect(result[0].title).toEqual('Investigation guide'); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index 92879c56e9885..d22bff896eb0b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -43,12 +43,15 @@ import { buildThreatMappingDescription, buildEqlOptionsDescription, buildRequiredFieldsDescription, + buildAlertSuppressionDescription, } from './helpers'; import { buildMlJobsDescription } from './ml_job_description'; import { buildActionsDescription } from './actions_description'; import { buildThrottleDescription } from './throttle_description'; import { THREAT_QUERY_LABEL } from './translations'; import { filterEmptyThreats } from '../../../../detection_engine/rule_creation_ui/pages/rule_creation/helpers'; +import { useLicense } from '../../../../common/hooks/use_license'; +import type { LicenseService } from '../../../../../common/license'; const DescriptionListContainer = styled(EuiDescriptionList)` &.euiDescriptionList--column .euiDescriptionList__title { @@ -74,6 +77,7 @@ export const StepRuleDescriptionComponent = ({ schema, }: StepRuleDescriptionProps) => { const kibana = useKibana(); + const license = useLicense(); const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); const keys = Object.keys(schema); @@ -96,7 +100,10 @@ export const StepRuleDescriptionComponent = ({ return [...acc, buildActionsDescription(get(key, data), get([key, 'label'], schema))]; } - return [...acc, ...buildListItems(data, pick(key, schema), filterManager, indexPatterns)]; + return [ + ...acc, + ...buildListItems(data, pick(key, schema), filterManager, license, indexPatterns), + ]; }, []); if (columns === 'multi') { @@ -137,6 +144,7 @@ export const buildListItems = ( data: unknown, schema: FormSchema, filterManager: FilterManager, + license: LicenseService, indexPatterns?: DataViewBase ): ListItems[] => Object.keys(schema).reduce( @@ -147,6 +155,7 @@ export const buildListItems = ( get([field, 'label'], schema), data, filterManager, + license, indexPatterns ), ], @@ -170,6 +179,7 @@ export const getDescriptionItem = ( label: string, data: unknown, filterManager: FilterManager, + license: LicenseService, indexPatterns?: DataViewBase ): ListItems[] => { if (field === 'queryBar') { @@ -186,6 +196,9 @@ export const getDescriptionItem = ( savedQueryName, indexPatterns, }); + } else if (field === 'groupByFields') { + const values: string[] = get(field, data); + return buildAlertSuppressionDescription(label, values, license); } else if (field === 'eqlOptions') { const eqlOptions: EqlOptionsSelected = get(field, data); return buildEqlOptionsDescription(eqlOptions); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx index acd2040ad3e27..ed7f761bec143 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx @@ -146,3 +146,11 @@ export const EQL_TIMESTAMP_FIELD_LABEL = i18n.translate( defaultMessage: 'Timestamp field', } ); + +export const ALERT_SUPPRESSION_INSUFFICIENT_LICENSE = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.alertSuppressionInsufficientLicense', + { + defaultMessage: + 'Alert suppression is configured but will not be applied due to insufficient licensing', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/group_by_fields/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/group_by_fields/index.tsx new file mode 100644 index 0000000000000..579167c73f4cb --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/group_by_fields/index.tsx @@ -0,0 +1,55 @@ +/* + * 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, { useMemo } from 'react'; + +import { EuiToolTip } from '@elastic/eui'; +import type { DataViewFieldBase } from '@kbn/es-query'; +import type { FieldHook } from '../../../../shared_imports'; +import { Field } from '../../../../shared_imports'; +import { GROUP_BY_FIELD_PLACEHOLDER, GROUP_BY_FIELD_LICENSE_WARNING } from './translations'; + +interface GroupByFieldsProps { + browserFields: DataViewFieldBase[]; + isDisabled: boolean; + field: FieldHook; +} + +const FIELD_COMBO_BOX_WIDTH = 410; + +const fieldDescribedByIds = 'detectionEngineStepDefineRuleGroupByField'; + +export const GroupByComponent: React.FC = ({ + browserFields, + isDisabled, + field, +}: GroupByFieldsProps) => { + const fieldEuiFieldProps = useMemo( + () => ({ + fullWidth: true, + noSuggestions: false, + options: browserFields.map((browserField) => ({ label: browserField.name })), + placeholder: GROUP_BY_FIELD_PLACEHOLDER, + onCreateOption: undefined, + style: { width: `${FIELD_COMBO_BOX_WIDTH}px` }, + isDisabled, + }), + [browserFields, isDisabled] + ); + const fieldComponent = ( + + ); + return isDisabled ? ( + + {fieldComponent} + + ) : ( + fieldComponent + ); +}; + +export const GroupByFields = React.memo(GroupByComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/group_by_fields/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/group_by_fields/translations.ts new file mode 100644 index 0000000000000..d0df6a7320015 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/group_by_fields/translations.ts @@ -0,0 +1,22 @@ +/* + * 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'; + +export const GROUP_BY_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.placeholderText', + { + defaultMessage: 'Select a field', + } +); + +export const GROUP_BY_FIELD_LICENSE_WARNING = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.licenseWarning', + { + defaultMessage: 'Alert suppression is enabled with Platinum license or above', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx index 5c5554b72ad8a..1da78d8d97a29 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx @@ -56,6 +56,7 @@ export const stepDefineStepMLRule: DefineStepRule = { timeline: { id: null, title: null }, eqlOptions: {}, dataSourceType: DataSourceType.IndexPatterns, + groupByFields: ['host.name'], newTermsFields: ['host.ip'], historyWindowSize: '7d', shouldLoadQueryDynamically: false, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 9cbe2362acb6d..8201863171227 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -78,6 +78,9 @@ import { ScheduleItem } from '../schedule_item_form'; import { DocLink } from '../../../../common/components/links_to_docs/doc_link'; import { defaultCustomQuery } from '../../../pages/detection_engine/rules/utils'; import { getIsRulePreviewDisabled } from '../rule_preview/helpers'; +import { GroupByFields } from '../group_by_fields'; +import { useLicense } from '../../../../common/hooks/use_license'; +import { minimumLicenseForSuppression } from '../../../../../common/detection_engine/rule_schema'; const CommonUseField = getUseField({ component: Field }); @@ -134,6 +137,7 @@ const StepDefineRuleComponent: FC = ({ const [indexModified, setIndexModified] = useState(false); const [threatIndexModified, setThreatIndexModified] = useState(false); const [dataViewTitle, setDataViewTitle] = useState(); + const license = useLicense(); const { form } = useForm({ defaultValue: initialState, @@ -771,6 +775,19 @@ const StepDefineRuleComponent: FC = ({ )} + + + + <> = { index: { @@ -557,6 +559,44 @@ export const schema: FormSchema = { }, ], }, + groupByFields: { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabel', + { + defaultMessage: 'Suppress Alerts By', + } + ), + labelAppend: OptionalFieldLabel, + helpText: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldGroupByFieldHelpText', + { + defaultMessage: 'Select field(s) to use for suppressing extra alerts', + } + ), + validations: [ + { + validator: ( + ...args: Parameters + ): ReturnType> | undefined => { + const [{ formData }] = args; + const needsValidation = isQueryRule(formData.ruleType); + if (!needsValidation) { + return; + } + return fieldValidators.maxLengthField({ + length: 3, + message: i18n.translate( + 'xpack.securitySolution.detectionEngine.validations.stepDefineRule.groupByFieldsMax', + { + defaultMessage: 'Number of grouping fields must be at most 3', + } + ), + })(...args); + }, + }, + ], + }, newTermsFields: { type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx index f6dfc40caa69a..e437ad1120c04 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -6,7 +6,10 @@ */ import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import { EuiIcon, EuiToolTip } from '@elastic/eui'; import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { find } from 'lodash/fp'; import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step'; import { isDetectionsAlertsTable } from '../../../common/components/top_n/helpers'; import { @@ -21,6 +24,12 @@ import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import type { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; +import { SUPPRESSED_ALERT_TOOLTIP } from './translations'; + +const SuppressedAlertIconWrapper = styled.div` + display: inline-flex; +`; + /** * This implementation of `EuiDataGrid`'s `renderCellValue` * accepts `EuiDataGridCellValueElementProps`, plus `data` @@ -39,7 +48,9 @@ export const RenderCellValue: React.FC ); + + return columnId === SIGNAL_RULE_NAME_FIELD_NAME && + suppressionCount?.value && + parseInt(suppressionCount.value[0], 10) > 0 ? ( + + + + +   + {component} + + ) : ( + component + ); }; export const useRenderCellValue = ({ diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/translations.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/translations.ts new file mode 100644 index 0000000000000..4f34bd2dc03ce --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const SUPPRESSED_ALERT_TOOLTIP = (numAlertsSuppressed: number) => + i18n.translate('xpack.securitySolution.configurations.suppressedAlerts', { + defaultMessage: 'Alert has {numAlertsSuppressed} suppressed alerts', + values: { numAlertsSuppressed }, + }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index 82934d0cccac2..1c7433c9caabd 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -115,6 +115,7 @@ describe('rule helpers', () => { eventCategoryField: undefined, tiebreakerField: undefined, }, + groupByFields: ['host.name'], newTermsFields: ['host.name'], historyWindowSize: '7d', }; @@ -258,6 +259,7 @@ describe('rule helpers', () => { eventCategoryField: undefined, tiebreakerField: undefined, }, + groupByFields: [], newTermsFields: [], historyWindowSize: '7d', shouldLoadQueryDynamically: true, @@ -312,6 +314,7 @@ describe('rule helpers', () => { eventCategoryField: undefined, tiebreakerField: undefined, }, + groupByFields: [], newTermsFields: [], historyWindowSize: '7d', shouldLoadQueryDynamically: false, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index 216e202052ee7..0452644b12d00 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -135,6 +135,7 @@ export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ ? convertHistoryStartToSize(rule.history_window_start) : '7d', shouldLoadQueryDynamically: Boolean(rule.type === 'saved_query' && rule.saved_id), + groupByFields: rule.alert_suppression?.group_by ?? [], }); const convertHistoryStartToSize = (relativeTime: string) => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index 81ebc4b24cf9c..680249fb8f89f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -168,6 +168,7 @@ export interface DefineStepRule { newTermsFields: string[]; historyWindowSize: string; shouldLoadQueryDynamically: boolean; + groupByFields: string[]; } export interface ScheduleStepRule { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts index 5c04e749e481d..e7cc2967ba62d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts @@ -143,6 +143,7 @@ export const stepDefineDefaultValue: DefineStepRule = { newTermsFields: [], historyWindowSize: '7d', shouldLoadQueryDynamically: false, + groupByFields: [], }; export const stepAboutDefaultValue: AboutStepRule = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts index 67462232a22ba..edb7c2c699882 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -98,4 +98,5 @@ export const getOutputRuleAlertForRest = (): RuleResponse => ({ timestamp_override_fallback_disabled: undefined, namespace: undefined, data_view_id: undefined, + alert_suppression: undefined, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/schedule_notification_actions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/schedule_notification_actions.test.ts index b82136e33acf2..a3f6b39cfa26c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/schedule_notification_actions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/schedule_notification_actions.test.ts @@ -62,6 +62,7 @@ describe('schedule_notification_actions', () => { relatedIntegrations: [], requiredFields: [], setup: '', + alertSuppression: undefined, }; it('Should schedule actions with unflatted and legacy context', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/schedule_throttle_notification_actions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/schedule_throttle_notification_actions.test.ts index 517f9b0a16f58..701da673efcd6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/schedule_throttle_notification_actions.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/schedule_throttle_notification_actions.test.ts @@ -63,6 +63,7 @@ describe('schedule_throttle_notification_actions', () => { relatedIntegrations: [], requiredFields: [], setup: '', + alertSuppression: undefined, }; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.test.ts index 703e0f9ec70ab..a36746623dbcd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/actions/duplicate_rule.test.ts @@ -61,6 +61,7 @@ describe('duplicateRule', () => { timestampOverride: undefined, timestampOverrideFallbackDisabled: undefined, dataViewId: undefined, + alertSuppression: undefined, }, schedule: { interval: '5m', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts index 0242f17509a99..02b83342cd846 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts @@ -221,6 +221,7 @@ describe('get_export_by_object_ids', () => { timestamp_override_fallback_disabled: undefined, namespace: undefined, data_view_id: undefined, + alert_suppression: undefined, }, ], }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts index 82d6ee6b1c4b2..ec3dcccf56eaa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/normalization/rule_converters.ts @@ -82,6 +82,7 @@ import { transformToAlertThrottle, transformToNotifyWhen, } from './rule_actions'; +import { convertAlertSuppressionToCamel, convertAlertSuppressionToSnake } from '../utils/utils'; // These functions provide conversions from the request API schema to the internal rule schema and from the internal rule schema // to the response API schema. This provides static type-check assurances that the internal schema is in sync with the API schema for @@ -137,6 +138,7 @@ export const typeSpecificSnakeToCamel = ( filters: params.filters, savedId: params.saved_id, responseActions: params.response_actions?.map(transformRuleToAlertResponseAction), + alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), }; } case 'saved_query': { @@ -149,6 +151,7 @@ export const typeSpecificSnakeToCamel = ( savedId: params.saved_id, dataViewId: params.data_view_id, responseActions: params.response_actions?.map(transformRuleToAlertResponseAction), + alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), }; } case 'threshold': { @@ -243,6 +246,7 @@ const patchQueryParams = ( responseActions: params.response_actions?.map(transformRuleToAlertResponseAction) ?? existingRule.responseActions, + alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), }; }; @@ -261,6 +265,7 @@ const patchSavedQueryParams = ( responseActions: params.response_actions?.map(transformRuleToAlertResponseAction) ?? existingRule.responseActions, + alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression), }; }; @@ -572,6 +577,7 @@ export const typeSpecificCamelToSnake = (params: TypeSpecificRuleParams): TypeSp filters: params.filters, saved_id: params.savedId, response_actions: params.responseActions?.map(transformAlertToRuleResponseAction), + alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression), }; } case 'saved_query': { @@ -584,6 +590,7 @@ export const typeSpecificCamelToSnake = (params: TypeSpecificRuleParams): TypeSp saved_id: params.savedId, data_view_id: params.dataViewId, response_actions: params.responseActions?.map(transformAlertToRuleResponseAction), + alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression), }; } case 'threshold': { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts index 3c8ca41801303..173e1a5f5b906 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/utils.ts @@ -16,12 +16,15 @@ import type { ActionsClient, FindActionResult } from '@kbn/actions-plugin/server import type { RuleToImport } from '../../../../../common/detection_engine/rule_management'; import type { RuleExecutionSummary } from '../../../../../common/detection_engine/rule_monitoring'; -import type { RuleResponse } from '../../../../../common/detection_engine/rule_schema'; +import type { + AlertSuppression, + RuleResponse, +} from '../../../../../common/detection_engine/rule_schema'; // eslint-disable-next-line no-restricted-imports import type { LegacyRulesActionsSavedObject } from '../../rule_actions_legacy'; import type { RuleExecutionSummariesByRuleId } from '../../rule_monitoring'; -import type { RuleAlertType, RuleParams } from '../../rule_schema'; +import type { AlertSuppressionCamel, RuleAlertType, RuleParams } from '../../rule_schema'; import { isAlertType } from '../../rule_schema'; import type { BulkError, OutputError } from '../../routes/utils'; import { createBulkErrorObject } from '../../routes/utils'; @@ -355,3 +358,21 @@ export const getInvalidConnectors = async ( return [Array.from(errors.values()), Array.from(rulesAcc.values())]; }; + +export const convertAlertSuppressionToCamel = ( + input: AlertSuppression | undefined +): AlertSuppressionCamel | undefined => + input + ? { + groupBy: input.group_by, + } + : undefined; + +export const convertAlertSuppressionToSnake = ( + input: AlertSuppressionCamel | undefined +): AlertSuppression | undefined => + input + ? { + group_by: input.groupBy, + } + : undefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.test.ts index 8d920ef4ba652..f90ce4a33b573 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.test.ts @@ -77,6 +77,7 @@ export const ruleOutput = (): RuleResponse => ({ namespace: undefined, data_view_id: undefined, saved_id: undefined, + alert_suppression: undefined, }); describe('validate', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts index 01bc55bf12467..5dbc62c86c417 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts @@ -7,6 +7,7 @@ import moment from 'moment'; import uuid from 'uuid'; import { transformError } from '@kbn/securitysolution-es-utils'; +import { QUERY_RULE_TYPE_ID, SAVED_QUERY_RULE_TYPE_ID } from '@kbn/securitysolution-rules'; import type { Logger, StartServicesAccessor } from '@kbn/core/server'; import type { IRuleDataClient } from '@kbn/rule-registry-plugin/server'; import type { @@ -51,7 +52,6 @@ import { createIndicatorMatchAlertType, createMlAlertType, createQueryAlertType, - createSavedQueryAlertType, createThresholdAlertType, createNewTermsAlertType, } from '../../../rule_types'; @@ -288,7 +288,12 @@ export const previewRulesRoute = async ( switch (previewRuleParams.type) { case 'query': const queryAlertType = previewRuleTypeWrapper( - createQueryAlertType({ ...ruleOptions, ...queryRuleAdditionalOptions }) + createQueryAlertType({ + ...ruleOptions, + ...queryRuleAdditionalOptions, + id: QUERY_RULE_TYPE_ID, + name: 'Custom Query Rule', + }) ); await runExecutors( queryAlertType.executor, @@ -308,7 +313,12 @@ export const previewRulesRoute = async ( break; case 'saved_query': const savedQueryAlertType = previewRuleTypeWrapper( - createSavedQueryAlertType({ ...ruleOptions, ...queryRuleAdditionalOptions }) + createQueryAlertType({ + ...ruleOptions, + ...queryRuleAdditionalOptions, + id: SAVED_QUERY_RULE_TYPE_ID, + name: 'Saved Query Rule', + }) ); await runExecutors( savedQueryAlertType.executor, @@ -403,9 +413,7 @@ export const previewRulesRoute = async ( ); break; case 'new_terms': - const newTermsAlertType = previewRuleTypeWrapper( - createNewTermsAlertType(ruleOptions, true) - ); + const newTermsAlertType = previewRuleTypeWrapper(createNewTermsAlertType(ruleOptions)); await runExecutors( newTermsAlertType.executor, newTermsAlertType.id, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.mock.ts index 04cec44000f78..3dcc8b0389cc9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.mock.ts @@ -125,6 +125,7 @@ export const getQueryRuleParams = (): QueryRuleParams => { }, ], savedId: undefined, + alertSuppression: undefined, responseActions: undefined, }; }; @@ -148,6 +149,7 @@ export const getSavedQueryRuleParams = (): SavedQueryRuleParams => { ], savedId: 'some-id', responseActions: undefined, + alertSuppression: undefined, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts index 0e64cc3788a12..5da3f3749da55 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_schema/model/rule_schemas.ts @@ -39,6 +39,7 @@ import type { SanitizedRuleConfig } from '@kbn/alerting-plugin/common'; import { AlertsIndex, AlertsIndexNamespace, + AlertSuppressionGroupBy, BuildingBlockType, DataViewId, EventCategoryOverride, @@ -85,6 +86,13 @@ import { ResponseActionRuleParamsOrUndefined } from '../../../../../common/detec const nonEqlLanguages = t.keyof({ kuery: null, lucene: null }); +export type AlertSuppressionCamel = t.TypeOf; +const AlertSuppressionCamel = t.exact( + t.type({ + groupBy: AlertSuppressionGroupBy, + }) +); + export const baseRuleParams = t.exact( t.type({ author: RuleAuthorArray, @@ -168,6 +176,7 @@ const querySpecificRuleParams = t.exact( savedId: savedIdOrUndefined, dataViewId: t.union([DataViewId, t.undefined]), responseActions: ResponseActionRuleParamsOrUndefined, + alertSuppression: t.union([AlertSuppressionCamel, t.undefined]), }) ); export const queryRuleParams = t.intersection([baseRuleParams, querySpecificRuleParams]); @@ -185,6 +194,7 @@ const savedQuerySpecificRuleParams = t.type({ filters: t.union([RuleFilterArray, t.undefined]), savedId: saved_id, responseActions: ResponseActionRuleParamsOrUndefined, + alertSuppression: t.union([AlertSuppressionCamel, t.undefined]), }); export const savedQueryRuleParams = t.intersection([baseRuleParams, savedQuerySpecificRuleParams]); export type SavedQuerySpecificRuleParams = t.TypeOf; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index 8ee773bb91cf7..8ec879c1e814c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -341,6 +341,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = secondaryTimestamp, ruleExecutionLogger, aggregatableTimestampField, + alertTimestampOverride, }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/wrap_suppressed_alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/wrap_suppressed_alerts.ts new file mode 100644 index 0000000000000..e708e3d906efc --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/wrap_suppressed_alerts.ts @@ -0,0 +1,87 @@ +/* + * 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 objectHash from 'object-hash'; +import type * as estypes from '@elastic/elasticsearch/lib/api/types'; +import { + ALERT_UUID, + ALERT_SUPPRESSION_TERMS, + ALERT_SUPPRESSION_DOCS_COUNT, + ALERT_SUPPRESSION_END, + ALERT_SUPPRESSION_START, +} from '@kbn/rule-data-utils'; +import type { + BaseFieldsLatest, + SuppressionFieldsLatest, + WrappedFieldsLatest, +} from '../../../../../../common/detection_engine/schemas/alerts'; +import type { ConfigType } from '../../../../../config'; +import type { CompleteRule, RuleParams } from '../../../rule_schema'; +import type { SignalSource } from '../../../signals/types'; +import { buildBulkBody } from './build_bulk_body'; +import type { BuildReasonMessage } from '../../../signals/reason_formatters'; + +export interface SuppressionBuckets { + event: estypes.SearchHit; + count: number; + start: Date; + end: Date; + terms: Array<{ field: string; value: string | number | null }>; +} + +export const wrapSuppressedAlerts = ({ + suppressionBuckets, + spaceId, + completeRule, + mergeStrategy, + indicesToQuery, + buildReasonMessage, + alertTimestampOverride, +}: { + suppressionBuckets: SuppressionBuckets[]; + spaceId: string | null | undefined; + completeRule: CompleteRule; + mergeStrategy: ConfigType['alertMergeStrategy']; + indicesToQuery: string[]; + buildReasonMessage: BuildReasonMessage; + alertTimestampOverride: Date | undefined; +}): Array> => { + return suppressionBuckets.map((bucket) => { + const id = objectHash([ + bucket.event._index, + bucket.event._id, + String(bucket.event._version), + `${spaceId}:${completeRule.alertId}`, + bucket.terms, + bucket.start, + bucket.end, + ]); + const baseAlert: BaseFieldsLatest = buildBulkBody( + spaceId, + completeRule, + bucket.event, + mergeStrategy, + [], + true, + buildReasonMessage, + indicesToQuery, + alertTimestampOverride + ); + return { + _id: id, + _index: '', + _source: { + ...baseAlert, + [ALERT_SUPPRESSION_TERMS]: bucket.terms, + [ALERT_SUPPRESSION_START]: bucket.start, + [ALERT_SUPPRESSION_END]: bucket.end, + [ALERT_SUPPRESSION_DOCS_COUNT]: bucket.count - 1, + [ALERT_UUID]: id, + }, + }; + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/index.ts index 9c5743aa1451d..8bcce2c7fd6c1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/index.ts @@ -9,6 +9,5 @@ export { createEqlAlertType } from './eql/create_eql_alert_type'; export { createIndicatorMatchAlertType } from './indicator_match/create_indicator_match_alert_type'; export { createMlAlertType } from './ml/create_ml_alert_type'; export { createQueryAlertType } from './query/create_query_alert_type'; -export { createSavedQueryAlertType } from './saved_query/create_saved_query_alert_type'; export { createThresholdAlertType } from './threshold/create_threshold_alert_type'; export { createNewTermsAlertType } from './new_terms/create_new_terms_alert_type'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts index bc2746ddf7888..8ef5b3acf4b2c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts @@ -43,8 +43,7 @@ import { import { createEnrichEventsFunction } from '../../signals/enrichments'; export const createNewTermsAlertType = ( - createOptions: CreateRuleOptions, - isPreview?: boolean + createOptions: CreateRuleOptions ): SecurityAlertType => { const { logger } = createOptions; return { @@ -107,12 +106,12 @@ export const createNewTermsAlertType = ( aggregatableTimestampField, exceptionFilter, unprocessedExceptions, + alertTimestampOverride, }, services, params, spaceId, state, - startedAt, } = execOptions; // Validate the history window size compared to `from` at runtime as well as in the `validate` @@ -288,7 +287,6 @@ export const createNewTermsAlertType = ( }; }); - const alertTimestampOverride = isPreview ? startedAt : undefined; const wrappedAlerts = wrapNewTermsAlerts({ eventsAndTerms, spaceId, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts index c2ecb5a88df8e..0478c4d49c1ba 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts @@ -17,6 +17,7 @@ import { ruleExecutionLogMock } from '../../rule_monitoring/mocks'; import { sampleDocNoSortId } from '../../signals/__mocks__/es_results'; import { getQueryRuleParams } from '../../rule_schema/mocks'; import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import { QUERY_RULE_TYPE_ID } from '@kbn/securitysolution-rules'; jest.mock('../../signals/utils', () => ({ ...jest.requireActual('../../signals/utils'), @@ -59,6 +60,8 @@ describe('Custom Query Alerts', () => { experimentalFeatures: allowedExperimentalValues, logger, version: '1.0.0', + id: QUERY_RULE_TYPE_ID, + name: 'Custom Query Rule', }) ); @@ -105,6 +108,8 @@ describe('Custom Query Alerts', () => { experimentalFeatures: allowedExperimentalValues, logger, version: '1.0.0', + id: QUERY_RULE_TYPE_ID, + name: 'Custom Query Rule', }) ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts index 4f246e5ada204..89a43f896a4d9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts @@ -6,23 +6,35 @@ */ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils'; -import { QUERY_RULE_TYPE_ID } from '@kbn/securitysolution-rules'; import { SERVER_APP_ID } from '../../../../../common/constants'; +import type { BucketHistory } from '../../signals/alert_suppression/group_and_bulk_create'; import type { UnifiedQueryRuleParams } from '../../rule_schema'; import { unifiedQueryRuleParams } from '../../rule_schema'; import { queryExecutor } from '../../signals/executors/query'; import type { CreateQueryRuleOptions, SecurityAlertType } from '../types'; import { validateIndexPatterns } from '../utils'; +export interface QueryRuleState { + suppressionGroupHistory?: BucketHistory[]; + [key: string]: unknown; +} + export const createQueryAlertType = ( createOptions: CreateQueryRuleOptions -): SecurityAlertType => { - const { eventsTelemetry, experimentalFeatures, version, osqueryCreateAction, licensing } = - createOptions; +): SecurityAlertType => { + const { + eventsTelemetry, + experimentalFeatures, + version, + osqueryCreateAction, + licensing, + id, + name, + } = createOptions; return { - id: QUERY_RULE_TYPE_ID, - name: 'Custom Query Rule', + id, + name, validate: { params: { validate: (object: unknown) => { @@ -62,47 +74,18 @@ export const createQueryAlertType = ( isExportable: false, producer: SERVER_APP_ID, async executor(execOptions) { - const { - runOpts: { - inputIndex, - runtimeMappings, - completeRule, - tuple, - listClient, - ruleExecutionLogger, - searchAfterSize, - bulkCreate, - wrapHits, - primaryTimestamp, - secondaryTimestamp, - unprocessedExceptions, - exceptionFilter, - }, - services, - state, - } = execOptions; - const result = await queryExecutor({ - completeRule, - tuple, - listClient, + const { runOpts, services, spaceId, state } = execOptions; + return queryExecutor({ + runOpts, experimentalFeatures, - ruleExecutionLogger, eventsTelemetry, services, version, - searchAfterSize, - bulkCreate, - wrapHits, - inputIndex, - runtimeMappings, - primaryTimestamp, - secondaryTimestamp, - unprocessedExceptions, - exceptionFilter, + spaceId, + bucketHistory: state.suppressionGroupHistory, osqueryCreateAction, licensing, }); - return { ...result, state }; }, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/saved_query/create_saved_query_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/saved_query/create_saved_query_alert_type.ts deleted file mode 100644 index 6e761bb6a51a0..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/saved_query/create_saved_query_alert_type.ts +++ /dev/null @@ -1,108 +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. - */ - -import { validateNonExact } from '@kbn/securitysolution-io-ts-utils'; -import { SAVED_QUERY_RULE_TYPE_ID } from '@kbn/securitysolution-rules'; -import { SERVER_APP_ID } from '../../../../../common/constants'; - -import type { CompleteRule, UnifiedQueryRuleParams } from '../../rule_schema'; -import { unifiedQueryRuleParams } from '../../rule_schema'; -import { queryExecutor } from '../../signals/executors/query'; -import type { CreateQueryRuleOptions, SecurityAlertType } from '../types'; -import { validateIndexPatterns } from '../utils'; - -export const createSavedQueryAlertType = ( - createOptions: CreateQueryRuleOptions -): SecurityAlertType => { - const { experimentalFeatures, version, osqueryCreateAction, licensing } = createOptions; - return { - id: SAVED_QUERY_RULE_TYPE_ID, - name: 'Saved Query Rule', - validate: { - params: { - validate: (object: unknown) => { - const [validated, errors] = validateNonExact(object, unifiedQueryRuleParams); - if (errors != null) { - throw new Error(errors); - } - if (validated == null) { - throw new Error('Validation of rule params failed'); - } - return validated; - }, - /** - * validate rule params when rule is bulk edited (update and created in future as well) - * returned params can be modified (useful in case of version increment) - * @param mutatedRuleParams - * @returns mutatedRuleParams - */ - validateMutatedParams: (mutatedRuleParams) => { - validateIndexPatterns(mutatedRuleParams.index); - - return mutatedRuleParams; - }, - }, - }, - actionGroups: [ - { - id: 'default', - name: 'Default', - }, - ], - defaultActionGroupId: 'default', - actionVariables: { - context: [{ name: 'server', description: 'the server' }], - }, - minimumLicenseRequired: 'basic', - isExportable: false, - producer: SERVER_APP_ID, - async executor(execOptions) { - const { - runOpts: { - inputIndex, - runtimeMappings, - completeRule, - tuple, - listClient, - ruleExecutionLogger, - searchAfterSize, - bulkCreate, - wrapHits, - primaryTimestamp, - secondaryTimestamp, - exceptionFilter, - unprocessedExceptions, - }, - services, - state, - } = execOptions; - - const result = await queryExecutor({ - inputIndex, - runtimeMappings, - completeRule: completeRule as CompleteRule, - tuple, - experimentalFeatures, - listClient, - ruleExecutionLogger, - eventsTelemetry: undefined, - services, - version, - searchAfterSize, - bulkCreate, - wrapHits, - primaryTimestamp, - secondaryTimestamp, - exceptionFilter, - unprocessedExceptions, - osqueryCreateAction, - licensing, - }); - return { ...result, state }; - }, - }; -}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts index ffd9e587361e9..4bbc32b371108 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts @@ -11,6 +11,8 @@ import type { Logger } from '@kbn/logging'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { QUERY_RULE_TYPE_ID, SAVED_QUERY_RULE_TYPE_ID } from '@kbn/securitysolution-rules'; + import type { RuleExecutorOptions, RuleType } from '@kbn/alerting-plugin/server'; import type { AlertInstanceContext, @@ -76,6 +78,7 @@ export interface RunOpts { aggregatableTimestampField: string; unprocessedExceptions: ExceptionListItemSchema[]; exceptionFilter: Filter | undefined; + alertTimestampOverride: Date | undefined; } export type SecurityAlertType< @@ -136,4 +139,7 @@ export interface CreateQueryRuleAdditionalOptions { export interface CreateQueryRuleOptions extends CreateRuleOptions, - CreateQueryRuleAdditionalOptions {} + CreateQueryRuleAdditionalOptions { + id: typeof QUERY_RULE_TYPE_ID | typeof SAVED_QUERY_RULE_TYPE_ID; + name: 'Custom Query Rule' | 'Saved Query Rule'; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 29ba601a75705..5bf47929cffa5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -517,6 +517,7 @@ export const sampleSignalHit = (): SignalHit => ({ data_view_id: undefined, filters: undefined, saved_id: undefined, + alert_suppression: undefined, }, depth: 1, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/__snapshots__/build_group_by_field_aggregation.test.ts.snap b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/__snapshots__/build_group_by_field_aggregation.test.ts.snap new file mode 100644 index 0000000000000..c1b21b1de1db1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/__snapshots__/build_group_by_field_aggregation.test.ts.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`build_group_by_field_aggregation Build Group-by-field aggregation 1`] = ` +Object { + "eventGroups": Object { + "aggs": Object { + "max_timestamp": Object { + "max": Object { + "field": "kibana.combined_timestamp", + }, + }, + "min_timestamp": Object { + "min": Object { + "field": "kibana.combined_timestamp", + }, + }, + "topHits": Object { + "top_hits": Object { + "size": 100, + "sort": Array [ + Object { + "kibana.combined_timestamp": Object { + "order": "asc", + "unmapped_type": "date", + }, + }, + ], + }, + }, + }, + "composite": Object { + "size": 100, + "sources": Array [ + Object { + "host.name": Object { + "terms": Object { + "field": "host.name", + }, + }, + }, + ], + }, + }, +} +`; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/__snapshots__/group_and_bulk_create.test.ts.snap b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/__snapshots__/group_and_bulk_create.test.ts.snap new file mode 100644 index 0000000000000..05674205e1e38 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/__snapshots__/group_and_bulk_create.test.ts.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`groupAndBulkCreate utils buildBucketHistoryFilter should create the expected query 1`] = ` +Array [ + Object { + "bool": Object { + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "host.name": "host-0", + }, + }, + Object { + "term": Object { + "source.ip": "127.0.0.1", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "strict_date_optional_time", + "gte": "2022-11-01T11:30:00.000Z", + "lte": "2022-11-01T12:00:00Z", + }, + }, + }, + ], + }, + }, + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "host.name": "host-1", + }, + }, + Object { + "term": Object { + "source.ip": "192.0.0.1", + }, + }, + Object { + "range": Object { + "@timestamp": Object { + "format": "strict_date_optional_time", + "gte": "2022-11-01T11:30:00.000Z", + "lte": "2022-11-01T12:05:00Z", + }, + }, + }, + ], + }, + }, + ], + }, + }, +] +`; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/build_group_by_field_aggregation.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/build_group_by_field_aggregation.test.ts new file mode 100644 index 0000000000000..010a2ab50ffab --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/build_group_by_field_aggregation.test.ts @@ -0,0 +1,22 @@ +/* + * 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 { buildGroupByFieldAggregation } from './build_group_by_field_aggregation'; + +describe('build_group_by_field_aggregation', () => { + it('Build Group-by-field aggregation', () => { + const groupByFields = ['host.name']; + const maxSignals = 100; + + const agg = buildGroupByFieldAggregation({ + groupByFields, + maxSignals, + aggregatableTimestampField: 'kibana.combined_timestamp', + }); + expect(agg).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/build_group_by_field_aggregation.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/build_group_by_field_aggregation.ts new file mode 100644 index 0000000000000..4df370d6bced9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/build_group_by_field_aggregation.ts @@ -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. + */ + +interface GetGroupByFieldAggregationArgs { + groupByFields: string[]; + maxSignals: number; + aggregatableTimestampField: string; +} + +export const buildGroupByFieldAggregation = ({ + groupByFields, + maxSignals, + aggregatableTimestampField, +}: GetGroupByFieldAggregationArgs) => ({ + eventGroups: { + composite: { + sources: groupByFields.map((field) => ({ + [field]: { + terms: { + field, + }, + }, + })), + size: maxSignals, + }, + aggs: { + topHits: { + top_hits: { + size: maxSignals, + sort: [ + { + [aggregatableTimestampField]: { + order: 'asc' as const, + unmapped_type: 'date', + }, + }, + ], + }, + }, + max_timestamp: { + max: { + field: aggregatableTimestampField, + }, + }, + min_timestamp: { + min: { + field: aggregatableTimestampField, + }, + }, + }, + }, +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/group_and_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/group_and_bulk_create.test.ts new file mode 100644 index 0000000000000..0a8fe775bb335 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/group_and_bulk_create.test.ts @@ -0,0 +1,58 @@ +/* + * 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 moment from 'moment'; +import { buildBucketHistoryFilter, filterBucketHistory } from './group_and_bulk_create'; +import type { BucketHistory } from './group_and_bulk_create'; + +describe('groupAndBulkCreate utils', () => { + const bucketHistory: BucketHistory[] = [ + { + key: { + 'host.name': 'host-0', + 'source.ip': '127.0.0.1', + }, + endDate: '2022-11-01T12:00:00Z', + }, + { + key: { + 'host.name': 'host-1', + 'source.ip': '192.0.0.1', + }, + endDate: '2022-11-01T12:05:00Z', + }, + ]; + + it('buildBucketHistoryFilter should create the expected query', () => { + const from = moment('2022-11-01T11:30:00Z'); + + const filter = buildBucketHistoryFilter({ + bucketHistory, + primaryTimestamp: '@timestamp', + secondaryTimestamp: undefined, + from, + }); + + expect(filter).toMatchSnapshot(); + }); + + it('filterBucketHistory should remove outdated buckets', () => { + const fromDate = new Date('2022-11-01T12:02:00Z'); + + const filteredBuckets = filterBucketHistory({ bucketHistory, fromDate }); + + expect(filteredBuckets).toEqual([ + { + key: { + 'host.name': 'host-1', + 'source.ip': '192.0.0.1', + }, + endDate: '2022-11-01T12:05:00Z', + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/group_and_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/group_and_bulk_create.ts new file mode 100644 index 0000000000000..690b0f698f306 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/alert_suppression/group_and_bulk_create.ts @@ -0,0 +1,218 @@ +/* + * 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 type moment from 'moment'; + +import type * as estypes from '@elastic/elasticsearch/lib/api/types'; + +import { withSecuritySpan } from '../../../../utils/with_security_span'; +import { buildTimeRangeFilter } from '../build_events_query'; +import type { + EventGroupingMultiBucketAggregationResult, + GroupAndBulkCreateParams, + GroupAndBulkCreateReturnType, +} from '../types'; +import { addToSearchAfterReturn, getUnprocessedExceptionsWarnings } from '../utils'; +import type { SuppressionBuckets } from '../../rule_types/factories/utils/wrap_suppressed_alerts'; +import { wrapSuppressedAlerts } from '../../rule_types/factories/utils/wrap_suppressed_alerts'; +import { buildGroupByFieldAggregation } from './build_group_by_field_aggregation'; +import { singleSearchAfter } from '../single_search_after'; + +export interface BucketHistory { + key: Record; + endDate: string; +} + +/** + * Builds a filter that excludes documents from existing buckets. + */ +export const buildBucketHistoryFilter = ({ + bucketHistory, + primaryTimestamp, + secondaryTimestamp, + from, +}: { + bucketHistory: BucketHistory[]; + primaryTimestamp: string; + secondaryTimestamp: string | undefined; + from: moment.Moment; +}): estypes.QueryDslQueryContainer[] | undefined => { + if (bucketHistory.length === 0) { + return undefined; + } + return [ + { + bool: { + must_not: bucketHistory.map((bucket) => ({ + bool: { + filter: [ + ...Object.entries(bucket.key).map(([field, value]) => ({ + term: { + [field]: value, + }, + })), + buildTimeRangeFilter({ + to: bucket.endDate, + from: from.toISOString(), + primaryTimestamp, + secondaryTimestamp, + }), + ], + }, + })), + }, + }, + ]; +}; + +export const filterBucketHistory = ({ + bucketHistory, + fromDate, +}: { + bucketHistory: BucketHistory[]; + fromDate: Date; +}) => { + return bucketHistory.filter((bucket) => new Date(bucket.endDate) > fromDate); +}; + +export const groupAndBulkCreate = async ({ + runOpts, + services, + spaceId, + filter, + buildReasonMessage, + bucketHistory, + groupByFields, +}: GroupAndBulkCreateParams): Promise => { + return withSecuritySpan('groupAndBulkCreate', async () => { + const tuple = runOpts.tuple; + + const filteredBucketHistory = filterBucketHistory({ + bucketHistory: bucketHistory ?? [], + fromDate: tuple.from.toDate(), + }); + + const toReturn: GroupAndBulkCreateReturnType = { + success: true, + warning: false, + searchAfterTimes: [], + enrichmentTimes: [], + bulkCreateTimes: [], + lastLookBackDate: null, + createdSignalsCount: 0, + createdSignals: [], + errors: [], + warningMessages: [], + state: { + suppressionGroupHistory: filteredBucketHistory, + }, + }; + + const exceptionsWarning = getUnprocessedExceptionsWarnings(runOpts.unprocessedExceptions); + if (exceptionsWarning) { + toReturn.warningMessages.push(exceptionsWarning); + } + + try { + if (groupByFields.length === 0) { + throw new Error('groupByFields length must be greater than 0'); + } + + const bucketHistoryFilter = buildBucketHistoryFilter({ + bucketHistory: filteredBucketHistory, + primaryTimestamp: runOpts.primaryTimestamp, + secondaryTimestamp: runOpts.secondaryTimestamp, + from: tuple.from, + }); + + const groupingAggregation = buildGroupByFieldAggregation({ + groupByFields, + maxSignals: tuple.maxSignals, + aggregatableTimestampField: runOpts.aggregatableTimestampField, + }); + + const { searchResult, searchDuration, searchErrors } = await singleSearchAfter({ + aggregations: groupingAggregation, + searchAfterSortIds: undefined, + index: runOpts.inputIndex, + from: tuple.from.toISOString(), + to: tuple.to.toISOString(), + services, + ruleExecutionLogger: runOpts.ruleExecutionLogger, + filter, + pageSize: 0, + primaryTimestamp: runOpts.primaryTimestamp, + secondaryTimestamp: runOpts.secondaryTimestamp, + runtimeMappings: runOpts.runtimeMappings, + additionalFilters: bucketHistoryFilter, + }); + toReturn.searchAfterTimes.push(searchDuration); + toReturn.errors.push(...searchErrors); + + const eventsByGroupResponseWithAggs = + searchResult as EventGroupingMultiBucketAggregationResult; + if (!eventsByGroupResponseWithAggs.aggregations) { + throw new Error('expected to find aggregations on search result'); + } + + const buckets = eventsByGroupResponseWithAggs.aggregations.eventGroups.buckets; + + if (buckets.length === 0) { + return toReturn; + } + + const suppressionBuckets: SuppressionBuckets[] = buckets.map((bucket) => ({ + event: bucket.topHits.hits.hits[0], + count: bucket.doc_count, + start: bucket.min_timestamp.value_as_string + ? new Date(bucket.min_timestamp.value_as_string) + : tuple.from.toDate(), + end: bucket.max_timestamp.value_as_string + ? new Date(bucket.max_timestamp.value_as_string) + : tuple.to.toDate(), + terms: Object.entries(bucket.key).map(([key, value]) => ({ field: key, value })), + })); + + const wrappedAlerts = wrapSuppressedAlerts({ + suppressionBuckets, + spaceId, + completeRule: runOpts.completeRule, + mergeStrategy: runOpts.mergeStrategy, + indicesToQuery: runOpts.inputIndex, + buildReasonMessage, + alertTimestampOverride: runOpts.alertTimestampOverride, + }); + + const bulkCreateResult = await runOpts.bulkCreate(wrappedAlerts); + + addToSearchAfterReturn({ current: toReturn, next: bulkCreateResult }); + + runOpts.ruleExecutionLogger.debug(`created ${bulkCreateResult.createdItemsCount} signals`); + + const newBucketHistory: BucketHistory[] = buckets + .filter((bucket) => { + return !Object.values(bucket.key).includes(null); + }) + .map((bucket) => { + return { + // This cast should be safe as we just filtered out buckets where any key has a null value. + key: bucket.key as Record, + endDate: bucket.max_timestamp.value_as_string + ? bucket.max_timestamp.value_as_string + : tuple.to.toISOString(), + }; + }); + + toReturn.state.suppressionGroupHistory.push(...newBucketHistory); + } catch (exc) { + toReturn.success = false; + toReturn.errors.push(exc.message); + } + + return toReturn; + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts index a5a8c4963f227..fdc53d6905a0b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts @@ -26,6 +26,7 @@ interface BuildEventsSearchQuery { primaryTimestamp: TimestampOverride; secondaryTimestamp: TimestampOverride | undefined; trackTotalHits?: boolean; + additionalFilters?: estypes.QueryDslQueryContainer[]; } interface BuildEqlSearchRequestParams { @@ -44,7 +45,7 @@ interface BuildEqlSearchRequestParams { exceptionFilter: Filter | undefined; } -const buildTimeRangeFilter = ({ +export const buildTimeRangeFilter = ({ to, from, primaryTimestamp, @@ -130,6 +131,7 @@ export const buildEventsSearchQuery = ({ primaryTimestamp, secondaryTimestamp, trackTotalHits, + additionalFilters, }: BuildEventsSearchQuery) => { const timestamps = secondaryTimestamp ? [primaryTimestamp, secondaryTimestamp] @@ -146,7 +148,11 @@ export const buildEventsSearchQuery = ({ secondaryTimestamp, }); - const filterWithTime: estypes.QueryDslQueryContainer[] = [filter, rangeFilter]; + const filterWithTime: estypes.QueryDslQueryContainer[] = [ + filter, + rangeFilter, + ...(additionalFilters ? additionalFilters : []), + ]; const sort: estypes.Sort = []; sort.push({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts index 52c0247b950db..7c3dc31b742ae 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts @@ -5,71 +5,49 @@ * 2.0. */ -import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import type { AlertInstanceContext, AlertInstanceState, RuleExecutorServices, } from '@kbn/alerting-plugin/server'; -import type { ListClient } from '@kbn/lists-plugin/server'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { firstValueFrom } from 'rxjs'; import type { LicensingPluginSetup } from '@kbn/licensing-plugin/server'; -import type { Filter } from '@kbn/es-query'; import { getFilter } from '../get_filter'; +import type { BucketHistory } from '../alert_suppression/group_and_bulk_create'; +import { groupAndBulkCreate } from '../alert_suppression/group_and_bulk_create'; import { searchAfterAndBulkCreate } from '../search_after_bulk_create'; -import type { RuleRangeTuple, BulkCreate, WrapHits } from '../types'; import type { ITelemetryEventsSender } from '../../../telemetry/sender'; -import type { CompleteRule, UnifiedQueryRuleParams } from '../../rule_schema'; +import type { UnifiedQueryRuleParams } from '../../rule_schema'; import type { ExperimentalFeatures } from '../../../../../common/experimental_features'; import { buildReasonMessageForQueryAlert } from '../reason_formatters'; import { withSecuritySpan } from '../../../../utils/with_security_span'; -import type { IRuleExecutionLogForExecutors } from '../../rule_monitoring'; import { scheduleNotificationResponseActions } from '../../rule_response_actions/schedule_notification_response_actions'; import type { SetupPlugins } from '../../../../plugin_contract'; +import type { RunOpts } from '../../rule_types/types'; export const queryExecutor = async ({ - inputIndex, - runtimeMappings, - completeRule, - tuple, - listClient, + runOpts, experimentalFeatures, - ruleExecutionLogger, eventsTelemetry, services, version, - searchAfterSize, - bulkCreate, - wrapHits, - primaryTimestamp, - secondaryTimestamp, - unprocessedExceptions, - exceptionFilter, + spaceId, + bucketHistory, osqueryCreateAction, licensing, }: { - inputIndex: string[]; - runtimeMappings: estypes.MappingRuntimeFields | undefined; - completeRule: CompleteRule; - tuple: RuleRangeTuple; - listClient: ListClient; + runOpts: RunOpts; experimentalFeatures: ExperimentalFeatures; - ruleExecutionLogger: IRuleExecutionLogForExecutors; eventsTelemetry: ITelemetryEventsSender | undefined; services: RuleExecutorServices; version: string; - searchAfterSize: number; - bulkCreate: BulkCreate; - wrapHits: WrapHits; - primaryTimestamp: string; - secondaryTimestamp?: string; - unprocessedExceptions: ExceptionListItemSchema[]; - exceptionFilter: Filter | undefined; + spaceId: string; + bucketHistory?: BucketHistory[]; osqueryCreateAction: SetupPlugins['osquery']['osqueryCreateAction']; licensing: LicensingPluginSetup; }) => { + const completeRule = runOpts.completeRule; const ruleParams = completeRule.ruleParams; return withSecuritySpan('queryExecutor', async () => { @@ -80,31 +58,46 @@ export const queryExecutor = async ({ query: ruleParams.query, savedId: ruleParams.savedId, services, - index: inputIndex, - exceptionFilter, - }); - - const result = await searchAfterAndBulkCreate({ - tuple, - exceptionsList: unprocessedExceptions, - services, - listClient, - ruleExecutionLogger, - eventsTelemetry, - inputIndexPattern: inputIndex, - pageSize: searchAfterSize, - filter: esFilter, - buildReasonMessage: buildReasonMessageForQueryAlert, - bulkCreate, - wrapHits, - runtimeMappings, - primaryTimestamp, - secondaryTimestamp, + index: runOpts.inputIndex, + exceptionFilter: runOpts.exceptionFilter, }); const license = await firstValueFrom(licensing.license$); + const hasPlatinumLicense = license.hasAtLeast('platinum'); const hasGoldLicense = license.hasAtLeast('gold'); + const result = + ruleParams.alertSuppression?.groupBy != null && hasPlatinumLicense + ? await groupAndBulkCreate({ + runOpts, + services, + spaceId, + filter: esFilter, + buildReasonMessage: buildReasonMessageForQueryAlert, + bucketHistory, + groupByFields: ruleParams.alertSuppression.groupBy, + }) + : { + ...(await searchAfterAndBulkCreate({ + tuple: runOpts.tuple, + exceptionsList: runOpts.unprocessedExceptions, + services, + listClient: runOpts.listClient, + ruleExecutionLogger: runOpts.ruleExecutionLogger, + eventsTelemetry, + inputIndexPattern: runOpts.inputIndex, + pageSize: runOpts.searchAfterSize, + filter: esFilter, + buildReasonMessage: buildReasonMessageForQueryAlert, + bulkCreate: runOpts.bulkCreate, + wrapHits: runOpts.wrapHits, + runtimeMappings: runOpts.runtimeMappings, + primaryTimestamp: runOpts.primaryTimestamp, + secondaryTimestamp: runOpts.secondaryTimestamp, + })), + state: {}, + }; + if (hasGoldLicense) { if (completeRule.ruleParams.responseActions?.length && result.createdSignalsCount) { scheduleNotificationResponseActions( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts index 04fec0e21a467..e1ef6e5867859 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts @@ -33,6 +33,7 @@ interface SingleSearchAfterParams { secondaryTimestamp: TimestampOverride | undefined; trackTotalHits?: boolean; runtimeMappings: estypes.MappingRuntimeFields | undefined; + additionalFilters?: estypes.QueryDslQueryContainer[]; } // utilize search_after for paging results into bulk. @@ -53,6 +54,7 @@ export const singleSearchAfter = async < primaryTimestamp, secondaryTimestamp, trackTotalHits, + additionalFilters, }: SingleSearchAfterParams): Promise<{ searchResult: SignalSearchResponse; searchDuration: string; @@ -73,6 +75,7 @@ export const singleSearchAfter = async < primaryTimestamp, secondaryTimestamp, trackTotalHits, + additionalFilters, }); const start = performance.now(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index e8dd19fd2ff8e..af354baa67903 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -7,6 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type moment from 'moment'; +import type { ESSearchResponse } from '@kbn/es-types'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import type { RuleTypeState, @@ -26,7 +27,7 @@ import type { EqlSequence, } from '../../../../common/detection_engine/types'; import type { ITelemetryEventsSender } from '../../telemetry/sender'; -import type { RuleParams } from '../rule_schema'; +import type { RuleParams, UnifiedQueryRuleParams } from '../rule_schema'; import type { GenericBulkCreateResponse } from '../rule_types/factories'; import type { BuildReasonMessage } from './reason_formatters'; import type { @@ -35,8 +36,11 @@ import type { WrappedFieldsLatest, } from '../../../../common/detection_engine/schemas/alerts'; import type { IRuleExecutionLogForExecutors } from '../rule_monitoring'; +import type { buildGroupByFieldAggregation } from './alert_suppression/build_group_by_field_aggregation'; import type { RuleResponse } from '../../../../common/detection_engine/rule_schema'; import type { EnrichEvents } from './enrichments/types'; +import type { BucketHistory } from './alert_suppression/group_and_bulk_create'; +import type { RunOpts } from '../rule_types/types'; export interface ThresholdResult { terms?: Array<{ @@ -282,6 +286,16 @@ export interface SearchAfterAndBulkCreateParams { secondaryTimestamp?: string; } +export interface GroupAndBulkCreateParams { + runOpts: RunOpts; + services: RuleServices; + spaceId: string; + filter: estypes.QueryDslQueryContainer; + buildReasonMessage: BuildReasonMessage; + bucketHistory?: BucketHistory[]; + groupByFields: string[]; +} + export interface SearchAfterAndBulkCreateReturnType { success: boolean; warning: boolean; @@ -295,6 +309,12 @@ export interface SearchAfterAndBulkCreateReturnType { warningMessages: string[]; } +export interface GroupAndBulkCreateReturnType extends SearchAfterAndBulkCreateReturnType { + state: { + suppressionGroupHistory: BucketHistory[]; + }; +} + export interface MultiAggBucket { cardinality?: Array<{ field: string; @@ -313,3 +333,12 @@ export interface ThresholdAlertState extends RuleTypeState { initialized: boolean; signalHistory: ThresholdSignalHistory; } + +export type EventGroupingMultiBucketAggregationResult = ESSearchResponse< + SignalSource, + { + body: { + aggregations: ReturnType; + }; + } +>; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index dd4993807f7c3..a4d9610b774ba 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -36,7 +36,6 @@ import { createMlAlertType, createNewTermsAlertType, createQueryAlertType, - createSavedQueryAlertType, createThresholdAlertType, } from './lib/detection_engine/rule_types'; import { initRoutes } from './routes'; @@ -260,7 +259,12 @@ export class Plugin implements ISecuritySolutionPlugin { plugins.alerting.registerType(securityRuleTypeWrapper(createEqlAlertType(ruleOptions))); plugins.alerting.registerType( securityRuleTypeWrapper( - createSavedQueryAlertType({ ...ruleOptions, ...queryRuleAdditionalOptions }) + createQueryAlertType({ + ...ruleOptions, + ...queryRuleAdditionalOptions, + id: SAVED_QUERY_RULE_TYPE_ID, + name: 'Saved Query Rule', + }) ) ); plugins.alerting.registerType( @@ -269,7 +273,12 @@ export class Plugin implements ISecuritySolutionPlugin { plugins.alerting.registerType(securityRuleTypeWrapper(createMlAlertType(ruleOptions))); plugins.alerting.registerType( securityRuleTypeWrapper( - createQueryAlertType({ ...ruleOptions, ...queryRuleAdditionalOptions }) + createQueryAlertType({ + ...ruleOptions, + ...queryRuleAdditionalOptions, + id: QUERY_RULE_TYPE_ID, + name: 'Custom Query Rule', + }) ) ); plugins.alerting.registerType(securityRuleTypeWrapper(createThresholdAlertType(ruleOptions))); diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts index b795e921f07cd..574418ed2758f 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts @@ -59,6 +59,7 @@ export const TIMELINE_EVENTS_FIELDS = [ ALERT_RISK_SCORE, 'kibana.alert.threshold_result', 'kibana.alert.building_block_type', + 'kibana.alert.suppression.docs_count', 'event.code', 'event.module', 'event.action', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_new_terms.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_new_terms.ts index cd961fce7aed0..c7ac470f1c8f2 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_new_terms.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_new_terms.ts @@ -18,7 +18,7 @@ export default ({ getService }: FtrProviderContext) => { const log = getService('log'); /** - * Specific api integration tests for threat matching rule type + * Specific api integration tests for new terms rule type */ describe('create_new_terms', () => { afterEach(async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts index 8090e4d2ce709..8ef0bf6b736dd 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts @@ -12,6 +12,10 @@ import { ALERT_RULE_RULE_ID, ALERT_SEVERITY, ALERT_WORKFLOW_STATUS, + ALERT_SUPPRESSION_START, + ALERT_SUPPRESSION_END, + ALERT_SUPPRESSION_DOCS_COUNT, + ALERT_SUPPRESSION_TERMS, } from '@kbn/rule-data-utils'; import { flattenWithPrefix } from '@kbn/securitysolution-rules'; @@ -422,5 +426,238 @@ export default ({ getService }: FtrProviderContext) => { const previewAlerts = await getPreviewAlerts({ es, previewId }); expect(previewAlerts.length).to.eql(1); }); + + describe('with suppression enabled', async () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/security_solution/suppression'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/security_solution/suppression'); + }); + + it('should generate only 1 alert per host name when grouping by host name', async () => { + const rule: QueryRuleCreateProps = { + ...getRuleForSignalTesting(['suppression-data']), + query: `host.name: "host-0"`, + alert_suppression: { + group_by: ['host.name'], + }, + from: 'now-1h', + interval: '1h', + }; + + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date('2020-10-28T05:30:00.000Z'), + }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + expect(previewAlerts.length).to.eql(1); + expect(previewAlerts[0]._source).to.eql({ + ...previewAlerts[0]._source, + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'host.name', + value: 'host-0', + }, + ], + [ALERT_ORIGINAL_TIME]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_START]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_END]: '2020-10-28T05:00:02.000Z', + [ALERT_SUPPRESSION_DOCS_COUNT]: 5, + }); + }); + + it('should generate multiple alerts when multiple host names are found', async () => { + const rule: QueryRuleCreateProps = { + ...getRuleForSignalTesting(['suppression-data']), + query: `host.name: *`, + alert_suppression: { + group_by: ['host.name'], + }, + from: 'now-1h', + interval: '1h', + }; + + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date('2020-10-28T05:30:00.000Z'), + }); + const previewAlerts = await getPreviewAlerts({ es, previewId, size: 1000 }); + expect(previewAlerts.length).to.eql(3); + + previewAlerts.sort((a, b) => + (a._source?.host?.name ?? '0') > (b._source?.host?.name ?? '0') ? 1 : -1 + ); + + const hostNames = previewAlerts.map((alert) => alert._source?.host?.name); + expect(hostNames).to.eql(['host-0', 'host-1', 'host-2']); + expect(previewAlerts[0]._source).to.eql({ + ...previewAlerts[0]._source, + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'host.name', + value: 'host-0', + }, + ], + [ALERT_ORIGINAL_TIME]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_START]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_END]: '2020-10-28T05:00:02.000Z', + [ALERT_SUPPRESSION_DOCS_COUNT]: 5, + }); + }); + + it('should generate alerts when using multiple group by fields', async () => { + const rule: QueryRuleCreateProps = { + ...getRuleForSignalTesting(['suppression-data']), + query: `host.name: *`, + alert_suppression: { + group_by: ['host.name', 'source.ip'], + }, + from: 'now-1h', + interval: '1h', + }; + + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date('2020-10-28T05:30:00.000Z'), + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + size: 1000, + sort: ['host.name', 'source.ip'], + }); + expect(previewAlerts.length).to.eql(6); + + expect(previewAlerts[0]._source).to.eql({ + ...previewAlerts[0]._source, + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'host.name', + value: 'host-0', + }, + { + field: 'source.ip', + value: '192.168.1.1', + }, + ], + [ALERT_ORIGINAL_TIME]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_START]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_END]: '2020-10-28T05:00:02.000Z', + [ALERT_SUPPRESSION_DOCS_COUNT]: 2, + }); + }); + + it('should not count documents that were covered by previous alerts', async () => { + const rule: QueryRuleCreateProps = { + ...getRuleForSignalTesting(['suppression-data']), + query: `host.name: *`, + alert_suppression: { + group_by: ['host.name', 'source.ip'], + }, + // The first invocation covers half of the source docs, the second invocation covers all documents. + // We will check and make sure the second invocation correctly filters out the first half that + // were alerted on by the first invocation. + from: 'now-2h', + interval: '1h', + }; + + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date('2020-10-28T06:30:00.000Z'), + invocationCount: 2, + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + size: 1000, + sort: ['host.name', 'source.ip', ALERT_ORIGINAL_TIME], + }); + expect(previewAlerts.length).to.eql(12); + + expect(previewAlerts[0]._source).to.eql({ + ...previewAlerts[0]._source, + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'host.name', + value: 'host-0', + }, + { + field: 'source.ip', + value: '192.168.1.1', + }, + ], + [ALERT_ORIGINAL_TIME]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_START]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_END]: '2020-10-28T05:00:02.000Z', + [ALERT_SUPPRESSION_DOCS_COUNT]: 2, + }); + + expect(previewAlerts[1]._source).to.eql({ + ...previewAlerts[1]._source, + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'host.name', + value: 'host-0', + }, + { + field: 'source.ip', + value: '192.168.1.1', + }, + ], + // Note: the timestamps here are 1 hour after the timestamps for previewAlerts[0] + [ALERT_ORIGINAL_TIME]: '2020-10-28T06:00:00.000Z', + [ALERT_SUPPRESSION_START]: '2020-10-28T06:00:00.000Z', + [ALERT_SUPPRESSION_END]: '2020-10-28T06:00:02.000Z', + [ALERT_SUPPRESSION_DOCS_COUNT]: 2, + }); + }); + + // Only one source document populates destination.ip, but it populates the field with an array + // so we expect 2 groups to be created from the single document + it('should generate multiple alerts for a single doc in multiple groups', async () => { + const rule: QueryRuleCreateProps = { + ...getRuleForSignalTesting(['suppression-data']), + query: `destination.ip: *`, + alert_suppression: { + group_by: ['destination.ip'], + }, + from: 'now-1h', + interval: '1h', + }; + + const { previewId } = await previewRule({ + supertest, + rule, + timeframeEnd: new Date('2020-10-28T05:30:00.000Z'), + }); + const previewAlerts = await getPreviewAlerts({ + es, + previewId, + size: 1000, + sort: ['destination.ip'], + }); + expect(previewAlerts.length).to.eql(2); + + expect(previewAlerts[0]._source).to.eql({ + ...previewAlerts[0]._source, + [ALERT_SUPPRESSION_TERMS]: [ + { + field: 'destination.ip', + value: '127.0.0.1', + }, + ], + [ALERT_ORIGINAL_TIME]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_START]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_END]: '2020-10-28T05:00:00.000Z', + [ALERT_SUPPRESSION_DOCS_COUNT]: 0, + }); + }); + }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/utils/get_preview_alerts.ts b/x-pack/test/detection_engine_api_integration/utils/get_preview_alerts.ts index 48682e6b1e8b0..2d9cdc0137546 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_preview_alerts.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_preview_alerts.ts @@ -20,10 +20,12 @@ export const getPreviewAlerts = async ({ es, previewId, size, + sort, }: { es: Client; previewId: string; size?: number; + sort?: string[]; }) => { const index = '.preview.alerts-security.alerts-*'; await refreshIndex(es, index); @@ -40,6 +42,7 @@ export const getPreviewAlerts = async ({ index, size, query, + sort, }); return result.hits.hits; }; diff --git a/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts b/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts index 9e869a91bf0b1..39b8d2acf088e 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_output.ts @@ -71,6 +71,7 @@ const getQueryRuleOutput = (ruleId = 'rule-1', enabled = false): RuleResponse => filters: undefined, saved_id: undefined, response_actions: undefined, + alert_suppression: undefined, }); /** diff --git a/x-pack/test/functional/es_archives/security_solution/suppression/data.json b/x-pack/test/functional/es_archives/security_solution/suppression/data.json new file mode 100644 index 0000000000000..6c22353d8227b --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/suppression/data.json @@ -0,0 +1,612 @@ +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:00.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1", + "destination.ip": ["127.0.0.1", "127.0.0.2"] + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:01.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:02.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:00.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:01.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:02.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:00.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:01.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:02.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:00.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:01.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:02.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:00.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:01.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:02.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:00.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:01.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T05:00:02.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:00.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:01.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:02.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:00.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:01.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:02.000Z", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:00.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:01.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:02.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:00.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:01.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:02.000Z", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:00.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:01.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:02.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:00.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:01.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "index": "suppression-data", + "source": { + "@timestamp": "2020-10-28T06:00:02.000Z", + "host": { + "name": "host-2", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "source.ip": "192.168.2.2" + }, + "type": "_doc" + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/security_solution/suppression/mappings.json b/x-pack/test/functional/es_archives/security_solution/suppression/mappings.json new file mode 100644 index 0000000000000..3222d9bcc490a --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/suppression/mappings.json @@ -0,0 +1,50 @@ +{ + "type": "index", + "value": { + "index": "suppression-data", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "host": { + "properties": { + "name": { + "type": "keyword" + }, + "ip": { + "type": "ip" + } + } + }, + "user": { + "properties": { + "name": { + "type": "keyword" + } + } + }, + "source": { + "properties": { + "ip": { + "type": "ip" + } + } + }, + "destination": { + "properties": { + "ip": { + "type": "ip" + } + } + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} From e957314ae440a910abec0524c01113732c5e80c6 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 15 Nov 2022 11:23:46 -0800 Subject: [PATCH 11/13] [DOCS] Add Opsgenie to create and update connector APIs (#145197) --- .../actions-and-connectors/create.asciidoc | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/api/actions-and-connectors/create.asciidoc b/docs/api/actions-and-connectors/create.asciidoc index c33cbdd77232c..b277a49d43723 100644 --- a/docs/api/actions-and-connectors/create.asciidoc +++ b/docs/api/actions-and-connectors/create.asciidoc @@ -83,6 +83,18 @@ For more information, refer to <>. For more information, refer to <>. ===== +.Opsgenie connectors +[%collapsible%open] +===== + +`apiUrl`:: +(Required, string) The Opsgenie URL. For example, `https://api.opsgenie.com` or +`https://api.eu.opsgenie.com`. If you are using the `xpack.actions.allowedHosts` +setting, make sure the hostname is added to the allowed hosts. + +For more information, refer to <>. +===== + .{sn-itom}, {sn-itsm}, and {sn-sir} connectors [%collapsible%open] ===== @@ -408,7 +420,7 @@ For more configuration properties, refer to <>. `connector_type_id`:: (Required, string) The connector type ID for the connector. For example, -`.cases-webhook`, `.index`, `.jira`, `.server-log`, or `.servicenow-itom`. +`.cases-webhook`, `.index`, `.jira`, `.opsgenie`, `.server-log`, or `.servicenow-itom`. `name`:: (Required, string) The display name for the connector. @@ -447,6 +459,14 @@ authentication. (Required, string) The account email for HTTP Basic authentication. ===== +.Opsgenie connectors +[%collapsible%open] +===== +`apiKey`:: +(Required, string) The Opsgenie API authentication key for HTTP Basic +authentication. +===== + .{sn-itom}, {sn-itsm}, and {sn-sir} connectors [%collapsible%open] ===== From 73f1705afe22c9417aa154f9893a00cf339b01a8 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 15 Nov 2022 11:25:33 -0800 Subject: [PATCH 12/13] [ResponseOps][Stack Connectors] Add helptext for Opsgenie connector (#145195) --- .../stack/opsgenie/close_alert.tsx | 9 +++++- .../create_alert/additional_options.tsx | 9 +++++- .../stack/opsgenie/create_alert/index.tsx | 7 ++++- .../opsgenie/create_alert/translations.ts | 3 +- .../stack/opsgenie/translations.ts | 28 +++++++++++++++++++ 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx index 82b0b5c061f77..8bca3b4a05a9a 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx @@ -40,6 +40,7 @@ const AdditionalOptions: React.FC = ({ data-test-subj="opsgenie-source-row" fullWidth label={i18n.SOURCE_FIELD_LABEL} + helpText={i18n.OPSGENIE_SOURCE_HELP} > = ({ - + = ({ error={errors['subActionParams.alias']} isInvalid={isAliasInvalid} label={i18n.ALIAS_REQUIRED_FIELD_LABEL} + helpText={i18n.OPSGENIE_ALIAS_HELP} > = ({ data-test-subj="opsgenie-entity-row" fullWidth label={i18n.ENTITY_FIELD_LABEL} + helpText={i18n.OPSGENIE_ENTITY_HELP} > = ({ data-test-subj="opsgenie-source-row" fullWidth label={i18n.SOURCE_FIELD_LABEL} + helpText={i18n.OPSGENIE_SOURCE_HELP} > = ({ - + = ({ inputTargetValue={subActionParams?.description} label={i18n.DESCRIPTION_FIELD_LABEL} /> - + Date: Tue, 15 Nov 2022 21:06:41 +0100 Subject: [PATCH 13/13] [Enterprise Search] Match simulate pipeline features with stack management (#145275) ## Summary Adds features to test pipeline with an existing document from the index. Also updated the text to give consistent messages across the other parts of the kibana https://user-images.githubusercontent.com/1410658/201979420-005b6f3e-c44c-4e44-b40e-88c3c717bb99.mov ## Release note Adds the ability to test Ingest pipelines with a document from the same index. ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) --- .../common/types/error_codes.ts | 1 + .../api/documents/get_document_logic.ts | 26 +++++ .../api/documents/get_documents_logic.test.ts | 42 +++++++ .../ml_inference/ml_inference_logic.test.ts | 99 +++++++++++++---- .../ml_inference/ml_inference_logic.ts | 72 +++++++++--- .../pipelines/ml_inference/test_pipeline.tsx | 103 +++++++++++++++--- .../lib/indices/document/get_document.ts | 21 ++++ .../routes/enterprise_search/documents.ts | 56 ++++++++++ .../server/routes/enterprise_search/index.ts | 2 + .../server/utils/identify_exceptions.ts | 3 + 10 files changed, 369 insertions(+), 56 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/documents/get_document_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/documents/get_documents_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/indices/document/get_document.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/enterprise_search/documents.ts diff --git a/x-pack/plugins/enterprise_search/common/types/error_codes.ts b/x-pack/plugins/enterprise_search/common/types/error_codes.ts index 79f1d2e69856c..a92f0025b8602 100644 --- a/x-pack/plugins/enterprise_search/common/types/error_codes.ts +++ b/x-pack/plugins/enterprise_search/common/types/error_codes.ts @@ -11,6 +11,7 @@ export enum ErrorCode { ANALYTICS_COLLECTION_NAME_INVALID = 'analytics_collection_name_invalid', CONNECTOR_DOCUMENT_ALREADY_EXISTS = 'connector_document_already_exists', CRAWLER_ALREADY_EXISTS = 'crawler_already_exists', + DOCUMENT_NOT_FOUND = 'document_not_found', INDEX_ALREADY_EXISTS = 'index_already_exists', INDEX_NOT_FOUND = 'index_not_found', PIPELINE_ALREADY_EXISTS = 'pipeline_already_exists', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/documents/get_document_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/documents/get_document_logic.ts new file mode 100644 index 0000000000000..5f5cf247f57f8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/documents/get_document_logic.ts @@ -0,0 +1,26 @@ +/* + * 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 { GetResponse } from '@elastic/elasticsearch/lib/api/types'; + +import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export interface GetDocumentsArgs { + documentId: string; + indexName: string; +} + +export type GetDocumentsResponse = GetResponse; + +export const getDocument = async ({ indexName, documentId }: GetDocumentsArgs) => { + const route = `/internal/enterprise_search/indices/${indexName}/document/${documentId}`; + + return await HttpLogic.values.http.get(route); +}; + +export const GetDocumentsApiLogic = createApiLogic(['get_documents_logic'], getDocument); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/documents/get_documents_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/documents/get_documents_logic.test.ts new file mode 100644 index 0000000000000..fb01683fff4cb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/documents/get_documents_logic.test.ts @@ -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 { mockHttpValues } from '../../../__mocks__/kea_logic'; + +import { nextTick } from '@kbn/test-jest-helpers'; + +import { getDocument } from './get_document_logic'; + +describe('getDocumentApiLogic', () => { + const { http } = mockHttpValues; + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getDocument', () => { + it('calls correct api', async () => { + const promise = Promise.resolve({ + _id: 'test-id', + _index: 'indexName', + _source: {}, + found: true, + }); + http.get.mockReturnValue(promise); + const result = getDocument({ documentId: '123123', indexName: 'indexName' }); + await nextTick(); + expect(http.get).toHaveBeenCalledWith( + '/internal/enterprise_search/indices/indexName/document/123123' + ); + await expect(result).resolves.toEqual({ + _id: 'test-id', + _index: 'indexName', + _source: {}, + found: true, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.test.ts index 2805b8389913d..fdc5bd5a92fd5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.test.ts @@ -13,6 +13,7 @@ import { TrainedModelConfigResponse } from '@kbn/ml-plugin/common/types/trained_ import { ErrorResponse, HttpError, Status } from '../../../../../../../common/types/api'; import { TrainedModelState } from '../../../../../../../common/types/pipelines'; +import { GetDocumentsApiLogic } from '../../../../api/documents/get_document_logic'; import { MappingsApiLogic } from '../../../../api/mappings/mappings_logic'; import { MLModelsApiLogic } from '../../../../api/ml_models/ml_models_logic'; import { AttachMlInferencePipelineApiLogic } from '../../../../api/pipelines/attach_ml_inference_pipeline'; @@ -35,22 +36,8 @@ const DEFAULT_VALUES: MLInferenceProcessorsValues = { ...EMPTY_PIPELINE_CONFIGURATION, }, indexName: '', - simulateBody: ` -[ - { - "_index": "index", - "_id": "id", - "_source": { - "foo": "bar" - } - }, - { - "_index": "index", - "_id": "id", - "_source": { - "foo": "baz" - } - } + simulateBody: `[ + ]`, step: AddInferencePipelineSteps.Configuration, }, @@ -61,7 +48,12 @@ const DEFAULT_VALUES: MLInferenceProcessorsValues = { pipelineName: 'Field is required.', sourceField: 'Field is required.', }, + getDocumentApiErrorMessage: undefined, + getDocumentApiStatus: Status.IDLE, + getDocumentData: undefined, + getDocumentsErr: '', index: null, + isGetDocumentsLoading: false, isLoading: true, isPipelineDataValid: false, mappingData: undefined, @@ -71,6 +63,7 @@ const DEFAULT_VALUES: MLInferenceProcessorsValues = { mlInferencePipelinesData: undefined, mlModelsData: undefined, mlModelsStatus: 0, + showGetDocumentErrors: false, simulateExistingPipelineData: undefined, simulateExistingPipelineStatus: 0, simulatePipelineData: undefined, @@ -103,6 +96,7 @@ describe('MlInferenceLogic', () => { const { mount: mountFetchMlInferencePipelinesApiLogic } = new LogicMounter( FetchMlInferencePipelinesApiLogic ); + const { mount: mountGetDocumentsApiLogic } = new LogicMounter(GetDocumentsApiLogic); beforeEach(() => { jest.clearAllMocks(); @@ -114,6 +108,7 @@ describe('MlInferenceLogic', () => { mountSimulateMlInterfacePipelineApiLogic(); mountCreateMlInferencePipelineApiLogic(); mountAttachMlInferencePipelineApiLogic(); + mountGetDocumentsApiLogic(); mount(); }); @@ -197,13 +192,35 @@ describe('MlInferenceLogic', () => { expect(MLInferenceLogic.values.createErrors).not.toHaveLength(0); MLInferenceLogic.actions.makeCreatePipelineRequest({ indexName: 'test', - pipelineName: 'unit-test', modelId: 'test-model', + pipelineName: 'unit-test', sourceField: 'body', }); expect(MLInferenceLogic.values.createErrors).toHaveLength(0); }); }); + describe('getDocumentApiSuccess', () => { + it('sets simulateBody text to the returned document', () => { + GetDocumentsApiLogic.actions.apiSuccess({ + _id: 'test-index-123', + _index: 'test-index', + found: true, + }); + expect(MLInferenceLogic.values.addInferencePipelineModal.simulateBody).toEqual( + JSON.stringify( + [ + { + _id: 'test-index-123', + _index: 'test-index', + found: true, + }, + ], + undefined, + 2 + ) + ); + }); + }); }); describe('selectors', () => { @@ -331,9 +348,9 @@ describe('MlInferenceLogic', () => { { destinationField: 'test-field', disabled: false, - pipelineName: 'unit-test', - modelType: '', modelId: 'test-model', + modelType: '', + pipelineName: 'unit-test', sourceField: 'body', }, ]); @@ -361,9 +378,9 @@ describe('MlInferenceLogic', () => { destinationField: 'test-field', disabled: true, disabledReason: expect.any(String), - pipelineName: 'unit-test', - modelType: '', modelId: 'test-model', + modelType: '', + pipelineName: 'unit-test', sourceField: 'body_content', }, ]); @@ -507,6 +524,46 @@ describe('MlInferenceLogic', () => { expect(MLInferenceLogic.values.mlInferencePipeline).toEqual(existingPipeline); }); }); + describe('getDocumentsErr', () => { + it('returns empty string when no error is present', () => { + GetDocumentsApiLogic.actions.apiSuccess({ + _id: 'test-123', + _index: 'test', + found: true, + }); + expect(MLInferenceLogic.values.getDocumentsErr).toEqual(''); + }); + it('returns extracted error message from the http response', () => { + GetDocumentsApiLogic.actions.apiError({ + body: { + error: 'document-not-found', + message: 'not-found', + statusCode: 404, + }, + } as HttpError); + expect(MLInferenceLogic.values.getDocumentsErr).toEqual('not-found'); + }); + }); + describe('showGetDocumentErrors', () => { + it('returns false when no error is present', () => { + GetDocumentsApiLogic.actions.apiSuccess({ + _id: 'test-123', + _index: 'test', + found: true, + }); + expect(MLInferenceLogic.values.showGetDocumentErrors).toEqual(false); + }); + it('returns true when an error message is present', () => { + GetDocumentsApiLogic.actions.apiError({ + body: { + error: 'document-not-found', + message: 'not-found', + statusCode: 404, + }, + } as HttpError); + expect(MLInferenceLogic.values.showGetDocumentErrors).toEqual(true); + }); + }); }); describe('listeners', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts index 4b92c43b2b304..001d4391bd94f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/ml_inference_logic.ts @@ -18,11 +18,17 @@ import { getMlModelTypesForModelConfig, parseMlInferenceParametersFromPipeline, } from '../../../../../../../common/ml_inference_pipeline'; -import { Status } from '../../../../../../../common/types/api'; +import { Status, HttpError } from '../../../../../../../common/types/api'; import { MlInferencePipeline } from '../../../../../../../common/types/pipelines'; import { Actions } from '../../../../../shared/api_logic/create_api_logic'; import { getErrorsFromHttpResponse } from '../../../../../shared/flash_messages/handle_api_errors'; + +import { + GetDocumentsApiLogic, + GetDocumentsArgs, + GetDocumentsResponse, +} from '../../../../api/documents/get_document_logic'; import { CachedFetchIndexApiLogic, CachedFetchIndexApiLogicValues, @@ -127,6 +133,8 @@ interface MLInferenceProcessorsActions { CreateMlInferencePipelineResponse >['apiSuccess']; createPipeline: () => void; + getDocumentApiError: Actions['apiError']; + getDocumentApiSuccess: Actions['apiSuccess']; makeAttachPipelineRequest: Actions< AttachMlInferencePipelineApiLogicArgs, AttachMlInferencePipelineResponse @@ -135,6 +143,7 @@ interface MLInferenceProcessorsActions { CreateMlInferencePipelineApiLogicArgs, CreateMlInferencePipelineResponse >['makeRequest']; + makeGetDocumentRequest: Actions['makeRequest']; makeMLModelsRequest: Actions['makeRequest']; makeMappingRequest: Actions['makeRequest']; makeMlInferencePipelinesRequest: Actions< @@ -204,7 +213,12 @@ export interface MLInferenceProcessorsValues { createErrors: string[]; existingInferencePipelines: MLInferencePipelineOption[]; formErrors: AddInferencePipelineFormErrors; + getDocumentApiErrorMessage: HttpError | undefined; + getDocumentApiStatus: Status; + getDocumentData: typeof GetDocumentsApiLogic.values.data; + getDocumentsErr: string; index: CachedFetchIndexApiLogicValues['indexData']; + isGetDocumentsLoading: boolean; isLoading: boolean; isPipelineDataValid: boolean; mappingData: typeof MappingsApiLogic.values.data; @@ -214,6 +228,7 @@ export interface MLInferenceProcessorsValues { mlInferencePipelinesData: FetchMlInferencePipelinesResponse | undefined; mlModelsData: TrainedModelConfigResponse[] | undefined; mlModelsStatus: Status; + showGetDocumentErrors: boolean; simulateExistingPipelineData: typeof SimulateExistingMlInterfacePipelineApiLogic.values.data; simulateExistingPipelineStatus: Status; simulatePipelineData: typeof SimulateMlInterfacePipelineApiLogic.values.data; @@ -278,6 +293,12 @@ export const MLInferenceLogic = kea< 'apiSuccess as attachApiSuccess', 'makeRequest as makeAttachPipelineRequest', ], + GetDocumentsApiLogic, + [ + 'apiError as getDocumentApiError', + 'apiSuccess as getDocumentApiSuccess', + 'makeRequest as makeGetDocumentRequest', + ], ], values: [ CachedFetchIndexApiLogic, @@ -294,6 +315,12 @@ export const MLInferenceLogic = kea< ['data as simulatePipelineData', 'status as simulatePipelineStatus'], FetchMlInferencePipelineProcessorsApiLogic, ['data as mlInferencePipelineProcessors'], + GetDocumentsApiLogic, + [ + 'data as getDocumentData', + 'status as getDocumentApiStatus', + 'error as getDocumentApiErrorMessage', + ], ], }, events: {}, @@ -375,26 +402,16 @@ export const MLInferenceLogic = kea< ...EMPTY_PIPELINE_CONFIGURATION, }, indexName: '', - simulateBody: ` -[ - { - "_index": "index", - "_id": "id", - "_source": { - "foo": "bar" - } - }, - { - "_index": "index", - "_id": "id", - "_source": { - "foo": "baz" - } - } + simulateBody: `[ + ]`, step: AddInferencePipelineSteps.Configuration, }, { + getDocumentApiSuccess: (modal, doc) => ({ + ...modal, + simulateBody: JSON.stringify([doc], undefined, 2), + }), setAddInferencePipelineStep: (modal, { step }) => ({ ...modal, step }), setIndexName: (modal, { indexName }) => ({ ...modal, indexName }), setInferencePipelineConfiguration: (modal, { configuration }) => ({ @@ -420,8 +437,8 @@ export const MLInferenceLogic = kea< [], { setSimulatePipelineErrors: (_, { errors }) => errors, - simulatePipelineApiError: (_, error) => getErrorsFromHttpResponse(error), simulateExistingPipelineApiError: (_, error) => getErrorsFromHttpResponse(error), + simulatePipelineApiError: (_, error) => getErrorsFromHttpResponse(error), }, ], }, @@ -431,6 +448,19 @@ export const MLInferenceLogic = kea< (modal: AddInferencePipelineModal) => validateInferencePipelineConfiguration(modal.configuration), ], + getDocumentsErr: [ + () => [selectors.getDocumentApiErrorMessage], + (err: MLInferenceProcessorsValues['getDocumentApiErrorMessage']) => { + if (!err) return ''; + return getErrorsFromHttpResponse(err)[0]; + }, + ], + isGetDocumentsLoading: [ + () => [selectors.getDocumentApiStatus], + (status) => { + return status === Status.LOADING; + }, + ], isLoading: [ () => [selectors.mlModelsStatus, selectors.mappingStatus], (mlModelsStatus, mappingStatus) => @@ -441,6 +471,12 @@ export const MLInferenceLogic = kea< () => [selectors.formErrors], (errors: AddInferencePipelineFormErrors) => Object.keys(errors).length === 0, ], + showGetDocumentErrors: [ + () => [selectors.getDocumentApiStatus], + (status: MLInferenceProcessorsValues['getDocumentApiStatus']) => { + return status === Status.ERROR; + }, + ], mlInferencePipeline: [ () => [ selectors.isPipelineDataValid, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/test_pipeline.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/test_pipeline.tsx index bd5b561426cfa..a1f7b316b9d48 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/test_pipeline.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/test_pipeline.tsx @@ -5,23 +5,26 @@ * 2.0. */ -import React from 'react'; +import React, { useRef } from 'react'; import { useValues, useActions } from 'kea'; import { - EuiCodeBlock, - EuiResizableContainer, EuiButton, - EuiText, + EuiCode, + EuiCodeBlock, + EuiFieldText, EuiFlexGroup, EuiFlexItem, - useIsWithinMaxBreakpoint, + EuiFormRow, + EuiResizableContainer, EuiSpacer, + EuiText, + useIsWithinMaxBreakpoint, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; - +import { FormattedMessage } from '@kbn/i18n-react'; import { CodeEditor } from '@kbn/kibana-react-plugin/public'; import { MLInferenceLogic } from './ml_inference_logic'; @@ -30,25 +33,81 @@ import './add_ml_inference_pipeline_modal.scss'; export const TestPipeline: React.FC = () => { const { - addInferencePipelineModal: { simulateBody }, + addInferencePipelineModal: { + configuration: { sourceField }, + indexName, + simulateBody, + }, + getDocumentsErr, + isGetDocumentsLoading, + showGetDocumentErrors, simulatePipelineResult, simulatePipelineErrors, } = useValues(MLInferenceLogic); - const { simulatePipeline, setPipelineSimulateBody } = useActions(MLInferenceLogic); + const { simulatePipeline, setPipelineSimulateBody, makeGetDocumentRequest } = + useActions(MLInferenceLogic); const isSmallerViewport = useIsWithinMaxBreakpoint('s'); + const inputRef = useRef(); return ( - -

- {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.test.title', - { defaultMessage: 'Review pipeline results (optional)' } - )} -

-
+ + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.test.title', + { defaultMessage: 'Review pipeline results (optional)' } + )} +

+
+ + +
+ {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.test.subtitle', + { defaultMessage: 'Documents' } + )} +
+
+
+ + + { + inputRef.current = ref; + }} + isInvalid={showGetDocumentErrors} + isLoading={isGetDocumentsLoading} + onKeyDown={(e) => { + if (e.key === 'Enter' && inputRef.current?.value.trim().length !== 0) { + makeGetDocumentRequest({ + documentId: inputRef.current?.value.trim() ?? '', + indexName, + }); + } + }} + /> + + +
{ - +

{i18n.translate( 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.test.description', @@ -98,6 +157,16 @@ export const TestPipeline: React.FC = () => { 'You can simulate your pipeline results by passing an array of documents.', } )} +
+ {`[{"_index":"index","_id":"id","_source":{"${sourceField}":"bar"}}]`} + ), + }} + />

diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/document/get_document.ts b/x-pack/plugins/enterprise_search/server/lib/indices/document/get_document.ts new file mode 100644 index 0000000000000..a2d21d32fad31 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/indices/document/get_document.ts @@ -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 { GetResponse } from '@elastic/elasticsearch/lib/api/types'; +import { IScopedClusterClient } from '@kbn/core/server'; + +export const getDocument = async ( + client: IScopedClusterClient, + indexName: string, + documentId: string +): Promise> => { + const response = await client.asCurrentUser.get({ + id: documentId, + index: indexName, + }); + return response; +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/documents.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/documents.ts new file mode 100644 index 0000000000000..47d36cfff07f5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/documents.ts @@ -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 { schema } from '@kbn/config-schema'; + +import { ErrorCode } from '../../../common/types/error_codes'; +import { getDocument } from '../../lib/indices/document/get_document'; +import { RouteDependencies } from '../../plugin'; +import { elasticsearchErrorHandler } from '../../utils/elasticsearch_error_handler'; +import { isNotFoundException } from '../../utils/identify_exceptions'; + +export function registerDocumentRoute({ router, log }: RouteDependencies) { + router.get( + { + path: '/internal/enterprise_search/indices/{index_name}/document/{document_id}', + validate: { + params: schema.object({ + document_id: schema.string(), + index_name: schema.string(), + }), + }, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const indexName = decodeURIComponent(request.params.index_name); + const documentId = decodeURIComponent(request.params.document_id); + const { client } = (await context.core).elasticsearch; + + try { + const documentResponse = await getDocument(client, indexName, documentId); + return response.ok({ + body: documentResponse, + headers: { 'content-type': 'application/json' }, + }); + } catch (error) { + if (isNotFoundException(error)) { + return response.customError({ + body: { + attributes: { + error_code: ErrorCode.DOCUMENT_NOT_FOUND, + }, + message: `Could not find document ${documentId}`, + }, + statusCode: 404, + }); + } else { + // otherwise, default handler + throw error; + } + } + }) + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/index.ts index ea3a7f3805d3f..1a609a22da8b8 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/index.ts @@ -7,6 +7,7 @@ import { RouteDependencies } from '../../plugin'; +import { registerDocumentRoute } from './documents'; import { registerIndexRoutes } from './indices'; import { registerMappingRoute } from './mapping'; import { registerSearchRoute } from './search'; @@ -15,4 +16,5 @@ export const registerEnterpriseSearchRoutes = (dependencies: RouteDependencies) registerIndexRoutes(dependencies); registerMappingRoute(dependencies); registerSearchRoute(dependencies); + registerDocumentRoute(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/utils/identify_exceptions.ts b/x-pack/plugins/enterprise_search/server/utils/identify_exceptions.ts index 9577eabfd31f3..5874659c690f8 100644 --- a/x-pack/plugins/enterprise_search/server/utils/identify_exceptions.ts +++ b/x-pack/plugins/enterprise_search/server/utils/identify_exceptions.ts @@ -33,3 +33,6 @@ export const isUnauthorizedException = (error: ElasticsearchResponseError) => export const isPipelineIsInUseException = (error: Error) => error.message === ErrorCode.PIPELINE_IS_IN_USE; + +export const isNotFoundException = (error: ElasticsearchResponseError) => + error.meta?.statusCode === 404;