diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index 59a315830aec5..43b3748231290 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -110,7 +110,7 @@ export const generalSettings: RawSettingDefinition[] = [ { text: 'critical', value: 'critical' }, { text: 'off', value: 'off' }, ], - includeAgents: ['dotnet', 'ruby', 'java'], + includeAgents: ['dotnet', 'ruby', 'java', 'python'], }, // Recording @@ -235,7 +235,7 @@ export const generalSettings: RawSettingDefinition[] = [ 'Sometimes it is necessary to sanitize, i.e., remove, sensitive data sent to Elastic APM. This config accepts a list of wildcard patterns of field names which should be sanitized. These apply to HTTP headers (including cookies) and `application/x-www-form-urlencoded` data (POST form fields). The query string and the captured request body (such as `application/json` data) will not get sanitized.', } ), - includeAgents: ['java'], + includeAgents: ['java', 'python'], }, // Ignore transactions based on URLs diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index a00f1ab5bb4d1..c9637f20a51bc 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -111,7 +111,9 @@ describe('filterByAgent', () => { 'api_request_time', 'capture_body', 'capture_headers', + 'log_level', 'recording', + 'sanitize_field_names', 'span_frames_min_duration', 'transaction_max_spans', 'transaction_sample_rate', diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index bcfe11851d1ea..4ee99eb51f44c 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -64,6 +64,7 @@ export enum SOURCE_TYPES { EMS_TMS = 'EMS_TMS', EMS_FILE = 'EMS_FILE', ES_GEO_GRID = 'ES_GEO_GRID', + ES_GEO_LINE = 'ES_GEO_LINE', ES_SEARCH = 'ES_SEARCH', ES_PEW_PEW = 'ES_PEW_PEW', ES_TERM_SOURCE = 'ES_TERM_SOURCE', diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index 68fc784182a77..eea201dcc8baa 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -34,7 +34,16 @@ type ESGeoGridSourceSyncMeta = { requestType: RENDER_AS; }; -export type VectorSourceSyncMeta = ESSearchSourceSyncMeta | ESGeoGridSourceSyncMeta | null; +type ESGeoLineSourceSyncMeta = { + splitField: string; + sortField: string; +}; + +export type VectorSourceSyncMeta = + | ESSearchSourceSyncMeta + | ESGeoGridSourceSyncMeta + | ESGeoLineSourceSyncMeta + | null; export type VectorSourceRequestMeta = MapFilters & { applyGlobalQuery: boolean; @@ -66,12 +75,21 @@ export type ESSearchSourceResponseMeta = { totalEntities?: number; }; +export type ESGeoLineSourceResponseMeta = { + areResultsTrimmed: boolean; + areEntitiesTrimmed: boolean; + entityCount: number; + numTrimmedTracks: number; + totalEntities: number; +}; + // Partial because objects are justified downstream in constructors export type DataMeta = Partial< VectorSourceRequestMeta & VectorJoinSourceRequestMeta & VectorStyleRequestMeta & - ESSearchSourceResponseMeta + ESSearchSourceResponseMeta & + ESGeoLineSourceResponseMeta >; type NumericalStyleFieldData = { diff --git a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts index c11ee59768a91..0e35b97a66bbf 100644 --- a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts @@ -72,6 +72,12 @@ export type ESGeoGridSourceDescriptor = AbstractESAggSourceDescriptor & { resolution: GRID_RESOLUTION; }; +export type ESGeoLineSourceDescriptor = AbstractESAggSourceDescriptor & { + geoField: string; + splitField: string; + sortField: string; +}; + export type ESSearchSourceDescriptor = AbstractESSourceDescriptor & { geoField: string; filterByMapBounds?: boolean; diff --git a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts index 278a3c0388b01..aac8afd4f292d 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts @@ -28,7 +28,9 @@ export type LayerWizard = { categories: LAYER_WIZARD_CATEGORY[]; checkVisibility?: () => Promise; description: string; + disabledReason?: string; icon: string | FunctionComponent; + getIsDisabled?: () => boolean; prerequisiteSteps?: Array<{ id: string; label: string }>; renderWizard(renderWizardArguments: RenderWizardArguments): ReactElement; title: string; diff --git a/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts b/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts index eaef7931b5e6c..b0f0965196830 100644 --- a/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts +++ b/x-pack/plugins/maps/public/classes/layers/load_layer_wizards.ts @@ -10,6 +10,7 @@ import { uploadLayerWizardConfig } from './file_upload_wizard'; import { esDocumentsLayerWizardConfig } from '../sources/es_search_source'; // @ts-ignore import { clustersLayerWizardConfig, heatmapLayerWizardConfig } from '../sources/es_geo_grid_source'; +import { geoLineLayerWizardConfig } from '../sources/es_geo_line_source'; // @ts-ignore import { point2PointLayerWizardConfig } from '../sources/es_pew_pew_source'; // @ts-ignore @@ -45,6 +46,7 @@ export function registerLayerWizards() { registerLayerWizard(clustersLayerWizardConfig); // @ts-ignore registerLayerWizard(heatmapLayerWizardConfig); + registerLayerWizard(geoLineLayerWizardConfig); // @ts-ignore registerLayerWizard(point2PointLayerWizardConfig); // @ts-ignore diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.test.ts new file mode 100644 index 0000000000000..de0f18fa537f6 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { convertToGeoJson } from './convert_to_geojson'; + +const esResponse = { + aggregations: { + tracks: { + buckets: { + ios: { + doc_count: 1, + path: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-95.339639, 41.584389], + [-95.339639, 41.0], + ], + }, + properties: { + complete: true, + }, + }, + }, + osx: { + doc_count: 1, + path: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-97.902775, 48.940572], + [-97.902775, 48.0], + ], + }, + properties: { + complete: false, + }, + }, + }, + }, + }, + }, +}; + +it('Should convert elasticsearch aggregation response into feature collection', () => { + const geoJson = convertToGeoJson(esResponse, 'machine.os.keyword'); + expect(geoJson.numTrimmedTracks).toBe(1); + expect(geoJson.featureCollection.features.length).toBe(2); + expect(geoJson.featureCollection.features[0]).toEqual({ + geometry: { + coordinates: [ + [-95.339639, 41.584389], + [-95.339639, 41.0], + ], + type: 'LineString', + }, + id: 'ios', + properties: { + complete: true, + doc_count: 1, + ['machine.os.keyword']: 'ios', + }, + type: 'Feature', + }); +}); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.ts new file mode 100644 index 0000000000000..a40b13bf07ae7 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/convert_to_geojson.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { Feature, FeatureCollection } from 'geojson'; +import { extractPropertiesFromBucket } from '../../../../common/elasticsearch_util'; + +const KEYS_TO_IGNORE = ['key', 'path']; + +export function convertToGeoJson(esResponse: any, entitySplitFieldName: string) { + const features: Feature[] = []; + let numTrimmedTracks = 0; + + const buckets = _.get(esResponse, 'aggregations.tracks.buckets', {}); + const entityKeys = Object.keys(buckets); + for (let i = 0; i < entityKeys.length; i++) { + const entityKey = entityKeys[i]; + const bucket = buckets[entityKey]; + const feature = bucket.path as Feature; + if (!feature.properties!.complete) { + numTrimmedTracks++; + } + feature.id = entityKey; + feature.properties = { + [entitySplitFieldName]: entityKey, + ...feature.properties, + ...extractPropertiesFromBucket(bucket, KEYS_TO_IGNORE), + }; + features.push(feature); + } + + return { + featureCollection: { + type: 'FeatureCollection', + features, + } as FeatureCollection, + numTrimmedTracks, + }; +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/create_source_editor.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/create_source_editor.tsx new file mode 100644 index 0000000000000..209f02bbd27b0 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/create_source_editor.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component } from 'react'; + +import { IndexPattern } from 'src/plugins/data/public'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiPanel } from '@elastic/eui'; +import { SingleFieldSelect } from '../../../components/single_field_select'; +import { GeoIndexPatternSelect } from '../../../components/geo_index_pattern_select'; + +import { getGeoPointFields } from '../../../index_pattern_util'; +import { GeoLineForm } from './geo_line_form'; + +interface Props { + onSourceConfigChange: ( + sourceConfig: { + indexPatternId: string; + geoField: string; + splitField: string; + sortField: string; + } | null + ) => void; +} + +interface State { + indexPattern: IndexPattern | null; + geoField: string; + splitField: string; + sortField: string; +} + +export class CreateSourceEditor extends Component { + state: State = { + indexPattern: null, + geoField: '', + splitField: '', + sortField: '', + }; + + _onIndexPatternSelect = (indexPattern: IndexPattern) => { + const pointFields = getGeoPointFields(indexPattern.fields); + this.setState( + { + indexPattern, + geoField: pointFields.length ? pointFields[0].name : '', + sortField: indexPattern.timeFieldName ? indexPattern.timeFieldName : '', + }, + this.previewLayer + ); + }; + + _onGeoFieldSelect = (geoField?: string) => { + if (geoField === undefined) { + return; + } + + this.setState( + { + geoField, + }, + this.previewLayer + ); + }; + + _onSplitFieldSelect = (newValue: string) => { + this.setState( + { + splitField: newValue, + }, + this.previewLayer + ); + }; + + _onSortFieldSelect = (newValue: string) => { + this.setState( + { + sortField: newValue, + }, + this.previewLayer + ); + }; + + previewLayer = () => { + const { indexPattern, geoField, splitField, sortField } = this.state; + + const sourceConfig = + indexPattern && indexPattern.id && geoField && splitField && sortField + ? { indexPatternId: indexPattern.id, geoField, splitField, sortField } + : null; + this.props.onSourceConfigChange(sourceConfig); + }; + + _renderGeoSelect() { + if (!this.state.indexPattern) { + return null; + } + + return ( + + + + ); + } + + _renderGeoLineForm() { + if (!this.state.indexPattern || !this.state.geoField) { + return null; + } + + return ( + + ); + } + + render() { + return ( + + + {this._renderGeoSelect()} + {this._renderGeoLineForm()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.test.ts new file mode 100644 index 0000000000000..6a173347f48a8 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESGeoLineSource } from './es_geo_line_source'; +import { DataRequest } from '../../util/data_request'; + +describe('getSourceTooltipContent', () => { + const geoLineSource = new ESGeoLineSource({ + indexPatternId: 'myindex', + geoField: 'myGeoField', + splitField: 'mySplitField', + sortField: 'mySortField', + }); + + it('Should not show results trimmed icon when number of entities is not trimmed and all tracks are complete', () => { + const sourceDataRequest = new DataRequest({ + data: {}, + dataId: 'source', + dataMeta: { + areResultsTrimmed: false, + areEntitiesTrimmed: false, + entityCount: 70, + numTrimmedTracks: 0, + totalEntities: 70, + }, + }); + const { tooltipContent, areResultsTrimmed } = geoLineSource.getSourceTooltipContent( + sourceDataRequest + ); + expect(areResultsTrimmed).toBe(false); + expect(tooltipContent).toBe('Found 70 tracks.'); + }); + + it('Should show results trimmed icon and message when number of entities are trimmed', () => { + const sourceDataRequest = new DataRequest({ + data: {}, + dataId: 'source', + dataMeta: { + areResultsTrimmed: true, + areEntitiesTrimmed: true, + entityCount: 1000, + numTrimmedTracks: 0, + totalEntities: 5000, + }, + }); + const { tooltipContent, areResultsTrimmed } = geoLineSource.getSourceTooltipContent( + sourceDataRequest + ); + expect(areResultsTrimmed).toBe(true); + expect(tooltipContent).toBe('Results limited to first 1000 tracks of ~5000.'); + }); + + it('Should show results trimmed icon and message when tracks are trimmed', () => { + const sourceDataRequest = new DataRequest({ + data: {}, + dataId: 'source', + dataMeta: { + areResultsTrimmed: false, + areEntitiesTrimmed: false, + entityCount: 70, + numTrimmedTracks: 10, + totalEntities: 70, + }, + }); + const { tooltipContent, areResultsTrimmed } = geoLineSource.getSourceTooltipContent( + sourceDataRequest + ); + expect(areResultsTrimmed).toBe(true); + expect(tooltipContent).toBe('Found 70 tracks. 10 of 70 tracks are incomplete.'); + }); + + it('Should show results trimmed icon and message when number of entities are trimmed. and tracks are trimmed', () => { + const sourceDataRequest = new DataRequest({ + data: {}, + dataId: 'source', + dataMeta: { + areResultsTrimmed: true, + areEntitiesTrimmed: true, + entityCount: 1000, + numTrimmedTracks: 10, + totalEntities: 5000, + }, + }); + const { tooltipContent, areResultsTrimmed } = geoLineSource.getSourceTooltipContent( + sourceDataRequest + ); + expect(areResultsTrimmed).toBe(true); + expect(tooltipContent).toBe( + 'Results limited to first 1000 tracks of ~5000. 10 of 1000 tracks are incomplete.' + ); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx new file mode 100644 index 0000000000000..d9b363d69d29c --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx @@ -0,0 +1,365 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; + +import { GeoJsonProperties } from 'geojson'; +import { i18n } from '@kbn/i18n'; +import { FIELD_ORIGIN, SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; +import { getField, addFieldToDSL } from '../../../../common/elasticsearch_util'; +import { + ESGeoLineSourceDescriptor, + ESGeoLineSourceResponseMeta, + VectorSourceRequestMeta, +} from '../../../../common/descriptor_types'; +import { getDataSourceLabel } from '../../../../common/i18n_getters'; +import { AbstractESAggSource } from '../es_agg_source'; +import { DataRequest } from '../../util/data_request'; +import { registerSource } from '../source_registry'; +import { convertToGeoJson } from './convert_to_geojson'; +import { ESDocField } from '../../fields/es_doc_field'; +import { UpdateSourceEditor } from './update_source_editor'; +import { ImmutableSourceProperty, SourceEditorArgs } from '../source'; +import { GeoJsonWithMeta } from '../vector_source'; +import { isValidStringConfig } from '../../util/valid_string_config'; +import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; +import { IField } from '../../fields/field'; +import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property'; +import { esFilters } from '../../../../../../../src/plugins/data/public'; +import { getIsGoldPlus } from '../../../licensed_features'; + +const MAX_TRACKS = 250; + +export const geoLineTitle = i18n.translate('xpack.maps.source.esGeoLineTitle', { + defaultMessage: 'Tracks', +}); + +export const REQUIRES_GOLD_LICENSE_MSG = i18n.translate( + 'xpack.maps.source.esGeoLineDisabledReason', + { + defaultMessage: '{title} requires a Gold license.', + values: { title: geoLineTitle }, + } +); + +export class ESGeoLineSource extends AbstractESAggSource { + static createDescriptor( + descriptor: Partial + ): ESGeoLineSourceDescriptor { + const normalizedDescriptor = AbstractESAggSource.createDescriptor( + descriptor + ) as ESGeoLineSourceDescriptor; + if (!isValidStringConfig(normalizedDescriptor.geoField)) { + throw new Error('Cannot create an ESGeoLineSource without a geoField'); + } + if (!isValidStringConfig(normalizedDescriptor.splitField)) { + throw new Error('Cannot create an ESGeoLineSource without a splitField'); + } + if (!isValidStringConfig(normalizedDescriptor.sortField)) { + throw new Error('Cannot create an ESGeoLineSource without a sortField'); + } + return { + ...normalizedDescriptor, + type: SOURCE_TYPES.ES_GEO_LINE, + geoField: normalizedDescriptor.geoField!, + splitField: normalizedDescriptor.splitField!, + sortField: normalizedDescriptor.sortField!, + }; + } + + readonly _descriptor: ESGeoLineSourceDescriptor; + + constructor(descriptor: Partial, inspectorAdapters?: Adapters) { + const sourceDescriptor = ESGeoLineSource.createDescriptor(descriptor); + super(sourceDescriptor, inspectorAdapters, true); + this._descriptor = sourceDescriptor; + } + + renderSourceSettingsEditor({ onChange }: SourceEditorArgs) { + return ( + + ); + } + + getSyncMeta() { + return { + splitField: this._descriptor.splitField, + sortField: this._descriptor.sortField, + }; + } + + async getImmutableProperties(): Promise { + let indexPatternTitle = this.getIndexPatternId(); + try { + const indexPattern = await this.getIndexPattern(); + indexPatternTitle = indexPattern.title; + } catch (error) { + // ignore error, title will just default to id + } + + return [ + { + label: getDataSourceLabel(), + value: geoLineTitle, + }, + { + label: i18n.translate('xpack.maps.source.esGeoLine.indexPatternLabel', { + defaultMessage: 'Index pattern', + }), + value: indexPatternTitle, + }, + { + label: i18n.translate('xpack.maps.source.esGeoLine.geospatialFieldLabel', { + defaultMessage: 'Geospatial field', + }), + value: this._descriptor.geoField, + }, + ]; + } + + _createSplitField(): IField { + return new ESDocField({ + fieldName: this._descriptor.splitField, + source: this, + origin: FIELD_ORIGIN.SOURCE, + canReadFromGeoJson: true, + }); + } + + getFieldNames() { + return [ + ...this.getMetricFields().map((esAggMetricField) => esAggMetricField.getName()), + this._descriptor.splitField, + this._descriptor.sortField, + ]; + } + + async getFields(): Promise { + return [...this.getMetricFields(), this._createSplitField()]; + } + + getFieldByName(name: string): IField | null { + return name === this._descriptor.splitField + ? this._createSplitField() + : this.getMetricFieldForName(name); + } + + isGeoGridPrecisionAware() { + return false; + } + + showJoinEditor() { + return false; + } + + async getGeoJsonWithMeta( + layerName: string, + searchFilters: VectorSourceRequestMeta, + registerCancelCallback: (callback: () => void) => void, + isRequestStillActive: () => boolean + ): Promise { + if (!getIsGoldPlus()) { + throw new Error(REQUIRES_GOLD_LICENSE_MSG); + } + + const indexPattern = await this.getIndexPattern(); + + // Request is broken into 2 requests + // 1) fetch entities: filtered by buffer so that top entities in view are returned + // 2) fetch tracks: not filtered by buffer to avoid having invalid tracks + // when the track extends beyond the area of the map buffer. + + // + // Fetch entities + // + const entitySearchSource = await this.makeSearchSource(searchFilters, 0); + const splitField = getField(indexPattern, this._descriptor.splitField); + const cardinalityAgg = { precision_threshold: 1 }; + const termsAgg = { size: MAX_TRACKS }; + entitySearchSource.setField('aggs', { + totalEntities: { + cardinality: addFieldToDSL(cardinalityAgg, splitField), + }, + entitySplit: { + terms: addFieldToDSL(termsAgg, splitField), + }, + }); + const entityResp = await this._runEsQuery({ + requestId: `${this.getId()}_entities`, + requestName: i18n.translate('xpack.maps.source.esGeoLine.entityRequestName', { + defaultMessage: '{layerName} entities', + values: { + layerName, + }, + }), + searchSource: entitySearchSource, + registerCancelCallback, + requestDescription: i18n.translate('xpack.maps.source.esGeoLine.entityRequestDescription', { + defaultMessage: 'Elasticsearch terms request to fetch entities within map buffer.', + }), + }); + const entityBuckets: Array<{ key: string; doc_count: number }> = _.get( + entityResp, + 'aggregations.entitySplit.buckets', + [] + ); + const totalEntities = _.get(entityResp, 'aggregations.totalEntities.value', 0); + const areEntitiesTrimmed = entityBuckets.length >= MAX_TRACKS; + + // + // Fetch tracks + // + const entityFilters: { [key: string]: unknown } = {}; + for (let i = 0; i < entityBuckets.length; i++) { + entityFilters[entityBuckets[i].key] = esFilters.buildPhraseFilter( + splitField, + entityBuckets[i].key, + indexPattern + ).query; + } + const tracksSearchFilters = { ...searchFilters }; + delete tracksSearchFilters.buffer; + const tracksSearchSource = await this.makeSearchSource(tracksSearchFilters, 0); + tracksSearchSource.setField('aggs', { + tracks: { + filters: { + filters: entityFilters, + }, + aggs: { + path: { + geo_line: { + point: { + field: this._descriptor.geoField, + }, + sort: { + field: this._descriptor.sortField, + }, + }, + }, + ...this.getValueAggsDsl(indexPattern), + }, + }, + }); + const tracksResp = await this._runEsQuery({ + requestId: `${this.getId()}_tracks`, + requestName: i18n.translate('xpack.maps.source.esGeoLine.trackRequestName', { + defaultMessage: '{layerName} tracks', + values: { + layerName, + }, + }), + searchSource: tracksSearchSource, + registerCancelCallback, + requestDescription: i18n.translate('xpack.maps.source.esGeoLine.trackRequestDescription', { + defaultMessage: + 'Elasticsearch geo_line request to fetch tracks for entities. Tracks are not filtered by map buffer.', + }), + }); + const { featureCollection, numTrimmedTracks } = convertToGeoJson( + tracksResp, + this._descriptor.splitField + ); + + return { + data: featureCollection, + meta: { + // meta.areResultsTrimmed is used by updateDueToExtent to skip re-fetching results + // when extent changes contained by original extent are not needed + // Only trigger re-fetch when the number of entities are trimmed + // Do not trigger re-fetch when tracks are trimmed since the tracks themselves are not filtered by map view extent. + areResultsTrimmed: areEntitiesTrimmed, + areEntitiesTrimmed, + entityCount: entityBuckets.length, + numTrimmedTracks, + totalEntities, + } as ESGeoLineSourceResponseMeta, + }; + } + + getSourceTooltipContent(sourceDataRequest?: DataRequest) { + const featureCollection = sourceDataRequest ? sourceDataRequest.getData() : null; + const meta = sourceDataRequest + ? (sourceDataRequest.getMeta() as ESGeoLineSourceResponseMeta) + : null; + if (!featureCollection || !meta) { + // no tooltip content needed when there is no feature collection or meta + return { + tooltipContent: null, + areResultsTrimmed: false, + }; + } + + const entitiesFoundMsg = meta.areEntitiesTrimmed + ? i18n.translate('xpack.maps.esGeoLine.areEntitiesTrimmedMsg', { + defaultMessage: `Results limited to first {entityCount} tracks of ~{totalEntities}.`, + values: { + entityCount: meta.entityCount, + totalEntities: meta.totalEntities, + }, + }) + : i18n.translate('xpack.maps.esGeoLine.tracksCountMsg', { + defaultMessage: `Found {entityCount} tracks.`, + values: { entityCount: meta.entityCount }, + }); + const tracksTrimmedMsg = + meta.numTrimmedTracks > 0 + ? i18n.translate('xpack.maps.esGeoLine.tracksTrimmedMsg', { + defaultMessage: `{numTrimmedTracks} of {entityCount} tracks are incomplete.`, + values: { + entityCount: meta.entityCount, + numTrimmedTracks: meta.numTrimmedTracks, + }, + }) + : undefined; + return { + tooltipContent: tracksTrimmedMsg + ? `${entitiesFoundMsg} ${tracksTrimmedMsg}` + : entitiesFoundMsg, + // Used to show trimmed icon in legend. Trimmed icon signals the following + // 1) number of entities are trimmed. + // 2) one or more tracks are incomplete. + areResultsTrimmed: meta.areEntitiesTrimmed || meta.numTrimmedTracks > 0, + }; + } + + isFilterByMapBounds() { + return true; + } + + canFormatFeatureProperties() { + return true; + } + + async getSupportedShapeTypes() { + return [VECTOR_SHAPE_TYPE.LINE]; + } + + async getTooltipProperties(properties: GeoJsonProperties): Promise { + const tooltipProperties = await super.getTooltipProperties(properties); + tooltipProperties.push( + new TooltipProperty( + 'isTrackComplete', + i18n.translate('xpack.maps.source.esGeoLine.isTrackCompleteLabel', { + defaultMessage: 'track is complete', + }), + properties!.complete.toString() + ) + ); + return tooltipProperties; + } +} + +registerSource({ + ConstructorFunction: ESGeoLineSource, + type: SOURCE_TYPES.ES_GEO_LINE, +}); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/geo_line_form.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/geo_line_form.tsx new file mode 100644 index 0000000000000..f0ccc72feeb42 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/geo_line_form.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { IndexPattern } from 'src/plugins/data/public'; +import { EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SingleFieldSelect } from '../../../components/single_field_select'; +import { getTermsFields } from '../../../index_pattern_util'; +import { indexPatterns } from '../../../../../../../src/plugins/data/public'; + +interface Props { + indexPattern: IndexPattern; + onSortFieldChange: (fieldName: string) => void; + onSplitFieldChange: (fieldName: string) => void; + sortField: string; + splitField: string; +} + +export function GeoLineForm(props: Props) { + function onSortFieldChange(fieldName: string | undefined) { + if (fieldName !== undefined) { + props.onSortFieldChange(fieldName); + } + } + function onSplitFieldChange(fieldName: string | undefined) { + if (fieldName !== undefined) { + props.onSplitFieldChange(fieldName); + } + } + return ( + <> + + + + + + { + const isSplitField = props.splitField ? field.name === props.splitField : false; + return !isSplitField && field.sortable && !indexPatterns.isNestedField(field); + })} + isClearable={false} + /> + + + ); +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/index.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/index.ts new file mode 100644 index 0000000000000..9ba46fabe12b0 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { geoLineLayerWizardConfig } from './layer_wizard'; +export { ESGeoLineSource } from './es_geo_line_source'; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx new file mode 100644 index 0000000000000..0738e8faec1e3 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { CreateSourceEditor } from './create_source_editor'; +import { ESGeoLineSource, geoLineTitle, REQUIRES_GOLD_LICENSE_MSG } from './es_geo_line_source'; +import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; +import { LAYER_WIZARD_CATEGORY, STYLE_TYPE, VECTOR_STYLES } from '../../../../common/constants'; +import { VectorStyle } from '../../styles/vector/vector_style'; +import { VectorLayer } from '../../layers/vector_layer/vector_layer'; +import { getIsGoldPlus } from '../../../licensed_features'; +import { TracksLayerIcon } from '../../layers/icons/tracks_layer_icon'; + +export const geoLineLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], + description: i18n.translate('xpack.maps.source.esGeoLineDescription', { + defaultMessage: 'Connect points into lines', + }), + disabledReason: REQUIRES_GOLD_LICENSE_MSG, + icon: TracksLayerIcon, + getIsDisabled: () => { + return !getIsGoldPlus(); + }, + renderWizard: ({ previewLayers }: RenderWizardArguments) => { + const onSourceConfigChange = ( + sourceConfig: { + indexPatternId: string; + geoField: string; + splitField: string; + sortField: string; + } | null + ) => { + if (!sourceConfig) { + previewLayers([]); + return; + } + + const layerDescriptor = VectorLayer.createDescriptor({ + sourceDescriptor: ESGeoLineSource.createDescriptor(sourceConfig), + style: VectorStyle.createDescriptor({ + [VECTOR_STYLES.LINE_WIDTH]: { + type: STYLE_TYPE.STATIC, + options: { + size: 2, + }, + }, + }), + }); + layerDescriptor.alpha = 1; + previewLayers([layerDescriptor]); + }; + + return ; + }, + title: geoLineTitle, +}; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/update_source_editor.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/update_source_editor.tsx new file mode 100644 index 0000000000000..1130b6d644903 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/update_source_editor.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, Component } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { + IFieldType, + IndexPattern, + indexPatterns, +} from '../../../../../../../src/plugins/data/public'; +import { MetricsEditor } from '../../../components/metrics_editor'; +import { getIndexPatternService } from '../../../kibana_services'; +import { GeoLineForm } from './geo_line_form'; +import { AggDescriptor } from '../../../../common/descriptor_types'; +import { OnSourceChangeArgs } from '../../../connected_components/layer_panel/view'; + +interface Props { + indexPatternId: string; + splitField: string; + sortField: string; + metrics: AggDescriptor[]; + onChange: (...args: OnSourceChangeArgs[]) => void; +} + +interface State { + indexPattern: IndexPattern | null; + fields: IFieldType[]; +} + +export class UpdateSourceEditor extends Component { + private _isMounted: boolean = false; + + state: State = { + indexPattern: null, + fields: [], + }; + + componentDidMount() { + this._isMounted = true; + this._loadFields(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadFields() { + let indexPattern; + try { + indexPattern = await getIndexPatternService().get(this.props.indexPatternId); + } catch (err) { + return; + } + + if (!this._isMounted) { + return; + } + + this.setState({ + indexPattern, + fields: indexPattern.fields.filter((field) => !indexPatterns.isNestedField(field)), + }); + } + + _onMetricsChange = (metrics: AggDescriptor[]) => { + this.props.onChange({ propName: 'metrics', value: metrics }); + }; + + _onSplitFieldChange = (fieldName: string) => { + this.props.onChange({ propName: 'splitField', value: fieldName }); + }; + + _onSortFieldChange = (fieldName: string) => { + this.props.onChange({ propName: 'sortField', value: fieldName }); + }; + + render() { + if (!this.state.indexPattern) { + return null; + } + + return ( + + + +
+ +
+
+ + +
+ + + + +
+ +
+
+ + +
+ +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/components/geo_index_pattern_select.tsx b/x-pack/plugins/maps/public/components/geo_index_pattern_select.tsx index 2e750e0648e53..ba87e2c869187 100644 --- a/x-pack/plugins/maps/public/components/geo_index_pattern_select.tsx +++ b/x-pack/plugins/maps/public/components/geo_index_pattern_select.tsx @@ -14,11 +14,12 @@ import { getIndexPatternService, getHttp, } from '../kibana_services'; -import { ES_GEO_FIELD_TYPES } from '../../common/constants'; +import { ES_GEO_FIELD_TYPE, ES_GEO_FIELD_TYPES } from '../../common/constants'; interface Props { onChange: (indexPattern: IndexPattern) => void; value: string | null; + isGeoPointsOnly?: boolean; } interface State { @@ -128,7 +129,9 @@ export class GeoIndexPatternSelect extends Component { placeholder={i18n.translate('xpack.maps.indexPatternSelectPlaceholder', { defaultMessage: 'Select index pattern', })} - fieldTypes={ES_GEO_FIELD_TYPES} + fieldTypes={ + this.props?.isGeoPointsOnly ? [ES_GEO_FIELD_TYPE.GEO_POINT] : ES_GEO_FIELD_TYPES + } onNoIndexPatterns={this._onNoIndexPatterns} isClearable={false} /> diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap index f8803d6339d9c..18e28b715680e 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap @@ -45,6 +45,7 @@ exports[`LayerWizardSelect Should render layer select after layer wizards are lo } + isDisabled={false} onClick={[Function]} title="wizard 2" titleSize="xs" diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.scss b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.scss new file mode 100644 index 0000000000000..73bbd2be3349c --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.scss @@ -0,0 +1,4 @@ +.mapMapLayerWizardSelect__tooltip { + display: flex; + flex: 1; +} diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx index 6f3a88ce905ce..7870f11530634 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx @@ -14,12 +14,14 @@ import { EuiLoadingContent, EuiFacetGroup, EuiFacetButton, + EuiToolTip, EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getLayerWizards, LayerWizard } from '../../../classes/layers/layer_wizard_registry'; import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import './layer_wizard_select.scss'; interface Props { onSelect: (layerWizard: LayerWizard) => void; @@ -150,16 +152,32 @@ export class LayerWizardSelect extends Component { this.props.onSelect(layerWizard); }; + const isDisabled = layerWizard.getIsDisabled ? layerWizard.getIsDisabled() : false; + const card = ( + + ); + return ( - + {isDisabled && layerWizard.disabledReason ? ( + + {card} + + ) : ( + card + )} ); }); diff --git a/x-pack/plugins/maps/public/index_pattern_util.ts b/x-pack/plugins/maps/public/index_pattern_util.ts index 68fd224dcbb45..79fa8f6eb6ddf 100644 --- a/x-pack/plugins/maps/public/index_pattern_util.ts +++ b/x-pack/plugins/maps/public/index_pattern_util.ts @@ -69,6 +69,12 @@ export function getGeoFields(fields: IFieldType[]): IFieldType[] { }); } +export function getGeoPointFields(fields: IFieldType[]): IFieldType[] { + return fields.filter((field) => { + return !indexPatterns.isNestedField(field) && ES_GEO_FIELD_TYPE.GEO_POINT === field.type; + }); +} + export function getFieldsWithGeoTileAgg(fields: IFieldType[]): IFieldType[] { return fields.filter(supportsGeoTileAgg); } diff --git a/x-pack/plugins/stack_alerts/common/config.ts b/x-pack/plugins/stack_alerts/common/config.ts index 2e997ce0ebad6..88d4699027425 100644 --- a/x-pack/plugins/stack_alerts/common/config.ts +++ b/x-pack/plugins/stack_alerts/common/config.ts @@ -8,7 +8,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), - enableGeoTrackingThresholdAlert: schema.boolean({ defaultValue: false }), + enableGeoAlerts: schema.boolean({ defaultValue: false }), }); export type Config = TypeOf; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/index.ts new file mode 100644 index 0000000000000..d3b5f14dcc9e7 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { validateExpression } from './validation'; +import { GeoContainmentAlertParams } from './types'; +import { AlertTypeModel, AlertsContextValue } from '../../../../triggers_actions_ui/public'; + +export function getAlertType(): AlertTypeModel { + return { + id: '.geo-containment', + name: i18n.translate('xpack.stackAlerts.geoContainment.name.trackingContainment', { + defaultMessage: 'Tracking containment', + }), + description: i18n.translate('xpack.stackAlerts.geoContainment.descriptionText', { + defaultMessage: 'Alert when an entity is contained within a geo boundary.', + }), + iconClass: 'globe', + documentationUrl: null, + alertParamsExpression: lazy(() => import('./query_builder')), + validate: validateExpression, + requiresAppContext: false, + }; +} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap new file mode 100644 index 0000000000000..cc8395455d89d --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/__snapshots__/geo_containment_alert_type_expression.test.tsx.snap @@ -0,0 +1,210 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render BoundaryIndexExpression 1`] = ` + + + + + + + + + + + + } +/> +`; + +exports[`should render EntityIndexExpression 1`] = ` + + + + + + } + labelType="label" + > + + + + + + + } +/> +`; + +exports[`should render EntityIndexExpression w/ invalid flag if invalid 1`] = ` + + + + + + } + labelType="label" + > + + + + + + + } +/> +`; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/boundary_index_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/boundary_index_expression.tsx new file mode 100644 index 0000000000000..a6a5aeb366cc5 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/boundary_index_expression.tsx @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FunctionComponent, useEffect, useRef } from 'react'; +import { EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { IErrorObject, AlertsContextValue } from '../../../../../../triggers_actions_ui/public'; +import { ES_GEO_SHAPE_TYPES, GeoContainmentAlertParams } from '../../types'; +import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select'; +import { SingleFieldSelect } from '../util_components/single_field_select'; +import { ExpressionWithPopover } from '../util_components/expression_with_popover'; +import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; +import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; + +interface Props { + alertParams: GeoContainmentAlertParams; + alertsContext: AlertsContextValue; + errors: IErrorObject; + boundaryIndexPattern: IIndexPattern; + boundaryNameField?: string; + setBoundaryIndexPattern: (boundaryIndexPattern?: IIndexPattern) => void; + setBoundaryGeoField: (boundaryGeoField?: string) => void; + setBoundaryNameField: (boundaryNameField?: string) => void; +} + +export const BoundaryIndexExpression: FunctionComponent = ({ + alertParams, + alertsContext, + errors, + boundaryIndexPattern, + boundaryNameField, + setBoundaryIndexPattern, + setBoundaryGeoField, + setBoundaryNameField, +}) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const BOUNDARY_NAME_ENTITY_TYPES = ['string', 'number', 'ip']; + const { dataUi, dataIndexPatterns, http } = alertsContext; + const IndexPatternSelect = (dataUi && dataUi.IndexPatternSelect) || null; + const { boundaryGeoField } = alertParams; + // eslint-disable-next-line react-hooks/exhaustive-deps + const nothingSelected: IFieldType = { + name: '', + type: 'string', + }; + + const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; + }; + + const oldIndexPattern = usePrevious(boundaryIndexPattern); + const fields = useRef<{ + geoFields: IFieldType[]; + boundaryNameFields: IFieldType[]; + }>({ + geoFields: [], + boundaryNameFields: [], + }); + useEffect(() => { + if (oldIndexPattern !== boundaryIndexPattern) { + fields.current.geoFields = + (boundaryIndexPattern.fields.length && + boundaryIndexPattern.fields.filter((field: IFieldType) => + ES_GEO_SHAPE_TYPES.includes(field.type) + )) || + []; + if (fields.current.geoFields.length) { + setBoundaryGeoField(fields.current.geoFields[0].name); + } + + fields.current.boundaryNameFields = [ + ...boundaryIndexPattern.fields.filter((field: IFieldType) => { + return ( + BOUNDARY_NAME_ENTITY_TYPES.includes(field.type) && + !field.name.startsWith('_') && + !field.name.endsWith('keyword') + ); + }), + nothingSelected, + ]; + if (fields.current.boundaryNameFields.length) { + setBoundaryNameField(fields.current.boundaryNameFields[0].name); + } + } + }, [ + BOUNDARY_NAME_ENTITY_TYPES, + boundaryIndexPattern, + nothingSelected, + oldIndexPattern, + setBoundaryGeoField, + setBoundaryNameField, + ]); + + const indexPopover = ( + + + { + if (!_indexPattern) { + return; + } + setBoundaryIndexPattern(_indexPattern); + }} + value={boundaryIndexPattern.id} + IndexPatternSelectComponent={IndexPatternSelect} + indexPatternService={dataIndexPatterns} + http={http} + includedGeoTypes={ES_GEO_SHAPE_TYPES} + /> + + + + + + { + setBoundaryNameField(name === nothingSelected.name ? undefined : name); + }} + fields={fields.current.boundaryNameFields} + /> + + + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.tsx new file mode 100644 index 0000000000000..129474e242270 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent, useEffect, useRef } from 'react'; +import { EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; +import { IErrorObject } from '../../../../../../triggers_actions_ui/public'; +import { SingleFieldSelect } from '../util_components/single_field_select'; +import { ExpressionWithPopover } from '../util_components/expression_with_popover'; +import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; + +interface Props { + errors: IErrorObject; + entity: string; + setAlertParamsEntity: (entity: string) => void; + indexFields: IFieldType[]; + isInvalid: boolean; +} + +export const EntityByExpression: FunctionComponent = ({ + errors, + entity, + setAlertParamsEntity, + indexFields, + isInvalid, +}) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const ENTITY_TYPES = ['string', 'number', 'ip']; + + const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; + }; + + const oldIndexFields = usePrevious(indexFields); + const fields = useRef<{ + indexFields: IFieldType[]; + }>({ + indexFields: [], + }); + useEffect(() => { + if (!_.isEqual(oldIndexFields, indexFields)) { + fields.current.indexFields = indexFields.filter( + (field: IFieldType) => ENTITY_TYPES.includes(field.type) && !field.name.startsWith('_') + ); + if (!entity && fields.current.indexFields.length) { + setAlertParamsEntity(fields.current.indexFields[0].name); + } + } + }, [ENTITY_TYPES, indexFields, oldIndexFields, setAlertParamsEntity, entity]); + + const indexPopover = ( + + _entity && setAlertParamsEntity(_entity)} + fields={fields.current.indexFields} + /> + + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_index_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_index_expression.tsx new file mode 100644 index 0000000000000..76edeac06ac9c --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_index_expression.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FunctionComponent, useEffect, useRef } from 'react'; +import { EuiFormRow } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + IErrorObject, + AlertsContextValue, + AlertTypeParamsExpressionProps, +} from '../../../../../../triggers_actions_ui/public'; +import { ES_GEO_FIELD_TYPES } from '../../types'; +import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select'; +import { SingleFieldSelect } from '../util_components/single_field_select'; +import { ExpressionWithPopover } from '../util_components/expression_with_popover'; +import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; +import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; + +interface Props { + dateField: string; + geoField: string; + alertsContext: AlertsContextValue; + errors: IErrorObject; + setAlertParamsDate: (date: string) => void; + setAlertParamsGeoField: (geoField: string) => void; + setAlertProperty: AlertTypeParamsExpressionProps['setAlertProperty']; + setIndexPattern: (indexPattern: IIndexPattern) => void; + indexPattern: IIndexPattern; + isInvalid: boolean; +} + +export const EntityIndexExpression: FunctionComponent = ({ + setAlertParamsDate, + setAlertParamsGeoField, + errors, + alertsContext, + setIndexPattern, + indexPattern, + isInvalid, + dateField: timeField, + geoField, +}) => { + const { dataUi, dataIndexPatterns, http } = alertsContext; + const IndexPatternSelect = (dataUi && dataUi.IndexPatternSelect) || null; + + const usePrevious = (value: T): T | undefined => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; + }; + + const oldIndexPattern = usePrevious(indexPattern); + const fields = useRef<{ + dateFields: IFieldType[]; + geoFields: IFieldType[]; + }>({ + dateFields: [], + geoFields: [], + }); + useEffect(() => { + if (oldIndexPattern !== indexPattern) { + fields.current.geoFields = + (indexPattern.fields.length && + indexPattern.fields.filter((field: IFieldType) => + ES_GEO_FIELD_TYPES.includes(field.type) + )) || + []; + if (fields.current.geoFields.length) { + setAlertParamsGeoField(fields.current.geoFields[0].name); + } + + fields.current.dateFields = + (indexPattern.fields.length && + indexPattern.fields.filter((field: IFieldType) => field.type === 'date')) || + []; + if (fields.current.dateFields.length) { + setAlertParamsDate(fields.current.dateFields[0].name); + } + } + }, [indexPattern, oldIndexPattern, setAlertParamsDate, setAlertParamsGeoField]); + + const indexPopover = ( + + + { + // reset time field and expression fields if indices are deleted + if (!_indexPattern) { + return; + } + setIndexPattern(_indexPattern); + }} + value={indexPattern.id} + IndexPatternSelectComponent={IndexPatternSelect} + indexPatternService={dataIndexPatterns} + http={http} + includedGeoTypes={ES_GEO_FIELD_TYPES} + /> + + + } + > + + _timeField && setAlertParamsDate(_timeField) + } + fields={fields.current.dateFields} + /> + + + + _geoField && setAlertParamsGeoField(_geoField) + } + fields={fields.current.geoFields} + /> + + + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/geo_containment_alert_type_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/geo_containment_alert_type_expression.test.tsx new file mode 100644 index 0000000000000..c35427bc6bc05 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/geo_containment_alert_type_expression.test.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EntityIndexExpression } from './expressions/entity_index_expression'; +import { BoundaryIndexExpression } from './expressions/boundary_index_expression'; +import { ApplicationStart, DocLinksStart, HttpSetup, ToastsStart } from 'kibana/public'; +import { + ActionTypeRegistryContract, + AlertTypeRegistryContract, + IErrorObject, +} from '../../../../../triggers_actions_ui/public'; +import { IIndexPattern } from '../../../../../../../src/plugins/data/common'; + +const alertsContext = { + http: (null as unknown) as HttpSetup, + alertTypeRegistry: (null as unknown) as AlertTypeRegistryContract, + actionTypeRegistry: (null as unknown) as ActionTypeRegistryContract, + toastNotifications: (null as unknown) as ToastsStart, + docLinks: (null as unknown) as DocLinksStart, + capabilities: (null as unknown) as ApplicationStart['capabilities'], +}; + +const alertParams = { + index: '', + indexId: '', + geoField: '', + entity: '', + dateField: '', + boundaryType: '', + boundaryIndexTitle: '', + boundaryIndexId: '', + boundaryGeoField: '', +}; + +test('should render EntityIndexExpression', async () => { + const component = shallow( + {}} + setAlertParamsGeoField={() => {}} + setAlertProperty={() => {}} + setIndexPattern={() => {}} + indexPattern={('' as unknown) as IIndexPattern} + isInvalid={false} + /> + ); + + expect(component).toMatchSnapshot(); +}); + +test('should render EntityIndexExpression w/ invalid flag if invalid', async () => { + const component = shallow( + {}} + setAlertParamsGeoField={() => {}} + setAlertProperty={() => {}} + setIndexPattern={() => {}} + indexPattern={('' as unknown) as IIndexPattern} + isInvalid={true} + /> + ); + + expect(component).toMatchSnapshot(); +}); + +test('should render BoundaryIndexExpression', async () => { + const component = shallow( + {}} + setBoundaryGeoField={() => {}} + setBoundaryNameField={() => {}} + boundaryNameField={'testNameField'} + /> + ); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/index.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/index.tsx new file mode 100644 index 0000000000000..1c0b712566d59 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/index.tsx @@ -0,0 +1,260 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useEffect, useState } from 'react'; +import { EuiCallOut, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + AlertTypeParamsExpressionProps, + AlertsContextValue, +} from '../../../../../triggers_actions_ui/public'; +import { GeoContainmentAlertParams } from '../types'; +import { EntityIndexExpression } from './expressions/entity_index_expression'; +import { EntityByExpression } from './expressions/entity_by_expression'; +import { BoundaryIndexExpression } from './expressions/boundary_index_expression'; +import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns'; +import { + esQuery, + esKuery, + Query, + QueryStringInput, +} from '../../../../../../../src/plugins/data/public'; + +const DEFAULT_VALUES = { + TRACKING_EVENT: '', + ENTITY: '', + INDEX: '', + INDEX_ID: '', + DATE_FIELD: '', + BOUNDARY_TYPE: 'entireIndex', // Only one supported currently. Will eventually be more + GEO_FIELD: '', + BOUNDARY_INDEX: '', + BOUNDARY_INDEX_ID: '', + BOUNDARY_GEO_FIELD: '', + BOUNDARY_NAME_FIELD: '', + DELAY_OFFSET_WITH_UNITS: '0m', +}; + +function validateQuery(query: Query) { + try { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + query.language === 'kuery' + ? esKuery.fromKueryExpression(query.query) + : esQuery.luceneStringToDsl(query.query); + } catch (err) { + return false; + } + return true; +} + +export const GeoContainmentAlertTypeExpression: React.FunctionComponent< + AlertTypeParamsExpressionProps +> = ({ alertParams, alertInterval, setAlertParams, setAlertProperty, errors, alertsContext }) => { + const { + index, + indexId, + indexQuery, + geoField, + entity, + dateField, + boundaryType, + boundaryIndexTitle, + boundaryIndexId, + boundaryIndexQuery, + boundaryGeoField, + boundaryNameField, + } = alertParams; + + const [indexPattern, _setIndexPattern] = useState({ + id: '', + fields: [], + title: '', + }); + const setIndexPattern = (_indexPattern?: IIndexPattern) => { + if (_indexPattern) { + _setIndexPattern(_indexPattern); + if (_indexPattern.title) { + setAlertParams('index', _indexPattern.title); + } + if (_indexPattern.id) { + setAlertParams('indexId', _indexPattern.id); + } + } + }; + const [indexQueryInput, setIndexQueryInput] = useState( + indexQuery || { + query: '', + language: 'kuery', + } + ); + const [boundaryIndexPattern, _setBoundaryIndexPattern] = useState({ + id: '', + fields: [], + title: '', + }); + const setBoundaryIndexPattern = (_indexPattern?: IIndexPattern) => { + if (_indexPattern) { + _setBoundaryIndexPattern(_indexPattern); + if (_indexPattern.title) { + setAlertParams('boundaryIndexTitle', _indexPattern.title); + } + if (_indexPattern.id) { + setAlertParams('boundaryIndexId', _indexPattern.id); + } + } + }; + const [boundaryIndexQueryInput, setBoundaryIndexQueryInput] = useState( + boundaryIndexQuery || { + query: '', + language: 'kuery', + } + ); + + const hasExpressionErrors = false; + const expressionErrorMessage = i18n.translate( + 'xpack.stackAlerts.geoContainment.fixErrorInExpressionBelowValidationMessage', + { + defaultMessage: 'Expression contains errors.', + } + ); + + useEffect(() => { + const initToDefaultParams = async () => { + setAlertProperty('params', { + ...alertParams, + index: index ?? DEFAULT_VALUES.INDEX, + indexId: indexId ?? DEFAULT_VALUES.INDEX_ID, + entity: entity ?? DEFAULT_VALUES.ENTITY, + dateField: dateField ?? DEFAULT_VALUES.DATE_FIELD, + boundaryType: boundaryType ?? DEFAULT_VALUES.BOUNDARY_TYPE, + geoField: geoField ?? DEFAULT_VALUES.GEO_FIELD, + boundaryIndexTitle: boundaryIndexTitle ?? DEFAULT_VALUES.BOUNDARY_INDEX, + boundaryIndexId: boundaryIndexId ?? DEFAULT_VALUES.BOUNDARY_INDEX_ID, + boundaryGeoField: boundaryGeoField ?? DEFAULT_VALUES.BOUNDARY_GEO_FIELD, + boundaryNameField: boundaryNameField ?? DEFAULT_VALUES.BOUNDARY_NAME_FIELD, + }); + if (!alertsContext.dataIndexPatterns) { + return; + } + if (indexId) { + const _indexPattern = await alertsContext.dataIndexPatterns.get(indexId); + setIndexPattern(_indexPattern); + } + if (boundaryIndexId) { + const _boundaryIndexPattern = await alertsContext.dataIndexPatterns.get(boundaryIndexId); + setBoundaryIndexPattern(_boundaryIndexPattern); + } + }; + initToDefaultParams(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {hasExpressionErrors ? ( + + + + + + ) : null} + + +
+ +
+
+ + setAlertParams('dateField', _date)} + setAlertParamsGeoField={(_geoField) => setAlertParams('geoField', _geoField)} + setAlertProperty={setAlertProperty} + setIndexPattern={setIndexPattern} + indexPattern={indexPattern} + isInvalid={!indexId || !dateField || !geoField} + /> + setAlertParams('entity', entityName)} + indexFields={indexPattern.fields} + isInvalid={indexId && dateField && geoField ? !entity : false} + /> + + + { + if (query.language) { + if (validateQuery(query)) { + setAlertParams('indexQuery', query); + } + setIndexQueryInput(query); + } + }} + /> + + + +
+ +
+
+ + + _geoField && setAlertParams('boundaryGeoField', _geoField) + } + setBoundaryNameField={(_boundaryNameField: string | undefined) => + _boundaryNameField + ? setAlertParams('boundaryNameField', _boundaryNameField) + : setAlertParams('boundaryNameField', '') + } + boundaryNameField={boundaryNameField} + /> + + + { + if (query.language) { + if (validateQuery(query)) { + setAlertParams('boundaryIndexQuery', query); + } + setBoundaryIndexQueryInput(query); + } + }} + /> + + +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export { GeoContainmentAlertTypeExpression as default }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/util_components/expression_with_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/util_components/expression_with_popover.tsx new file mode 100644 index 0000000000000..2e067ac42c531 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/util_components/expression_with_popover.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ReactNode, useState } from 'react'; +import { + EuiButtonIcon, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const ExpressionWithPopover: ({ + popoverContent, + expressionDescription, + defaultValue, + value, + isInvalid, +}: { + popoverContent: ReactNode; + expressionDescription: ReactNode; + defaultValue?: ReactNode; + value?: ReactNode; + isInvalid?: boolean; +}) => JSX.Element = ({ popoverContent, expressionDescription, defaultValue, value, isInvalid }) => { + const [popoverOpen, setPopoverOpen] = useState(false); + + return ( + setPopoverOpen(true)} + isInvalid={isInvalid} + /> + } + isOpen={popoverOpen} + closePopover={() => setPopoverOpen(false)} + ownFocus + anchorPosition="downLeft" + zIndex={8000} + display="block" + > +
+ + + {expressionDescription} + + setPopoverOpen(false)} + /> + + + + {popoverContent} +
+
+ ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/util_components/geo_index_pattern_select.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/util_components/geo_index_pattern_select.tsx new file mode 100644 index 0000000000000..66ab8f2dc300e --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/util_components/geo_index_pattern_select.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component } from 'react'; +import { EuiCallOut, EuiFormRow, EuiLink, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { IndexPattern, IndexPatternsContract } from 'src/plugins/data/public'; +import { HttpSetup } from 'kibana/public'; + +interface Props { + onChange: (indexPattern: IndexPattern) => void; + value: string | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + IndexPatternSelectComponent: any; + indexPatternService: IndexPatternsContract | undefined; + http: HttpSetup; + includedGeoTypes: string[]; +} + +interface State { + noGeoIndexPatternsExist: boolean; +} + +export class GeoIndexPatternSelect extends Component { + private _isMounted: boolean = false; + + state = { + noGeoIndexPatternsExist: false, + }; + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + } + + _onIndexPatternSelect = async (indexPatternId: string) => { + if (!indexPatternId || indexPatternId.length === 0 || !this.props.indexPatternService) { + return; + } + + let indexPattern; + try { + indexPattern = await this.props.indexPatternService.get(indexPatternId); + } catch (err) { + return; + } + + // method may be called again before 'get' returns + // ignore response when fetched index pattern does not match active index pattern + if (this._isMounted && indexPattern.id === indexPatternId) { + this.props.onChange(indexPattern); + } + }; + + _onNoIndexPatterns = () => { + this.setState({ noGeoIndexPatternsExist: true }); + }; + + _renderNoIndexPatternWarning() { + if (!this.state.noGeoIndexPatternsExist) { + return null; + } + + return ( + <> + +

+ + + + + +

+

+ + + + +

+
+ + + ); + } + + render() { + const IndexPatternSelectComponent = this.props.IndexPatternSelectComponent; + return ( + <> + {this._renderNoIndexPatternWarning()} + + + {IndexPatternSelectComponent ? ( + + ) : ( +
+ )} + + + ); + } +} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/util_components/single_field_select.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/util_components/single_field_select.tsx new file mode 100644 index 0000000000000..ef6e6f6f5e18f --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/util_components/single_field_select.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiHighlight, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { IFieldType } from 'src/plugins/data/public'; +import { FieldIcon } from '../../../../../../../../src/plugins/kibana_react/public'; + +function fieldsToOptions(fields?: IFieldType[]): Array> { + if (!fields) { + return []; + } + + return fields + .map((field) => ({ + value: field, + label: field.name, + })) + .sort((a, b) => { + return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); + }); +} + +interface Props { + placeholder: string; + value: string | null; // index pattern field name + onChange: (fieldName?: string) => void; + fields: IFieldType[]; +} + +export function SingleFieldSelect({ placeholder, value, onChange, fields }: Props) { + function renderOption( + option: EuiComboBoxOptionOption, + searchValue: string, + contentClassName: string + ) { + return ( + + + + + + {option.label} + + + ); + } + + const onSelection = (selectedOptions: Array>) => { + onChange(_.get(selectedOptions, '0.value.name')); + }; + + const selectedOptions: Array> = []; + if (value && fields) { + const selectedField = fields.find((field: IFieldType) => field.name === value); + if (selectedField) { + selectedOptions.push({ value: selectedField, label: value }); + } + } + + return ( + + ); +} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts new file mode 100644 index 0000000000000..89252f7c90104 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Query } from '../../../../../../src/plugins/data/common'; + +export interface GeoContainmentAlertParams { + index: string; + indexId: string; + geoField: string; + entity: string; + dateField: string; + boundaryType: string; + boundaryIndexTitle: string; + boundaryIndexId: string; + boundaryGeoField: string; + boundaryNameField?: string; + delayOffsetWithUnits?: string; + indexQuery?: Query; + boundaryIndexQuery?: Query; +} + +// Will eventually include 'geo_shape' +export const ES_GEO_FIELD_TYPES = ['geo_point']; +export const ES_GEO_SHAPE_TYPES = ['geo_shape']; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/validation.test.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/validation.test.ts new file mode 100644 index 0000000000000..607e420979344 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/validation.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GeoContainmentAlertParams } from './types'; +import { validateExpression } from './validation'; + +describe('expression params validation', () => { + test('if index property is invalid should return proper error message', () => { + const initialParams: GeoContainmentAlertParams = { + index: '', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.index[0]).toBe('Index pattern is required.'); + }); + + test('if geoField property is invalid should return proper error message', () => { + const initialParams: GeoContainmentAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: '', + entity: 'testField', + dateField: 'testField', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.geoField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.geoField[0]).toBe('Geo field is required.'); + }); + + test('if entity property is invalid should return proper error message', () => { + const initialParams: GeoContainmentAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: '', + dateField: 'testField', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.entity.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.entity[0]).toBe('Entity is required.'); + }); + + test('if dateField property is invalid should return proper error message', () => { + const initialParams: GeoContainmentAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: '', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.dateField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.dateField[0]).toBe('Date field is required.'); + }); + + test('if boundaryType property is invalid should return proper error message', () => { + const initialParams: GeoContainmentAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + boundaryType: '', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.boundaryType.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.boundaryType[0]).toBe( + 'Boundary type is required.' + ); + }); + + test('if boundaryIndexTitle property is invalid should return proper error message', () => { + const initialParams: GeoContainmentAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + boundaryType: 'testType', + boundaryIndexTitle: '', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + }; + expect(validateExpression(initialParams).errors.boundaryIndexTitle.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.boundaryIndexTitle[0]).toBe( + 'Boundary index pattern title is required.' + ); + }); + + test('if boundaryGeoField property is invalid should return proper error message', () => { + const initialParams: GeoContainmentAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: '', + }; + expect(validateExpression(initialParams).errors.boundaryGeoField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.boundaryGeoField[0]).toBe( + 'Boundary geo field is required.' + ); + }); + + test('if boundaryNameField property is missing should not return error', () => { + const initialParams: GeoContainmentAlertParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndexId', + boundaryGeoField: 'testField', + boundaryNameField: '', + }; + expect(validateExpression(initialParams).errors.boundaryGeoField.length).toBe(0); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/validation.ts new file mode 100644 index 0000000000000..cf40b28a64a21 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/validation.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { ValidationResult } from '../../../../triggers_actions_ui/public'; +import { GeoContainmentAlertParams } from './types'; + +export const validateExpression = (alertParams: GeoContainmentAlertParams): ValidationResult => { + const { + index, + geoField, + entity, + dateField, + boundaryType, + boundaryIndexTitle, + boundaryGeoField, + } = alertParams; + const validationResult = { errors: {} }; + const errors = { + index: new Array(), + indexId: new Array(), + geoField: new Array(), + entity: new Array(), + dateField: new Array(), + boundaryType: new Array(), + boundaryIndexTitle: new Array(), + boundaryIndexId: new Array(), + boundaryGeoField: new Array(), + }; + validationResult.errors = errors; + + if (!index) { + errors.index.push( + i18n.translate('xpack.stackAlerts.geoContainment.error.requiredIndexTitleText', { + defaultMessage: 'Index pattern is required.', + }) + ); + } + + if (!geoField) { + errors.geoField.push( + i18n.translate('xpack.stackAlerts.geoContainment.error.requiredGeoFieldText', { + defaultMessage: 'Geo field is required.', + }) + ); + } + + if (!entity) { + errors.entity.push( + i18n.translate('xpack.stackAlerts.geoContainment.error.requiredEntityText', { + defaultMessage: 'Entity is required.', + }) + ); + } + + if (!dateField) { + errors.dateField.push( + i18n.translate('xpack.stackAlerts.geoContainment.error.requiredDateFieldText', { + defaultMessage: 'Date field is required.', + }) + ); + } + + if (!boundaryType) { + errors.boundaryType.push( + i18n.translate('xpack.stackAlerts.geoContainment.error.requiredBoundaryTypeText', { + defaultMessage: 'Boundary type is required.', + }) + ); + } + + if (!boundaryIndexTitle) { + errors.boundaryIndexTitle.push( + i18n.translate('xpack.stackAlerts.geoContainment.error.requiredBoundaryIndexTitleText', { + defaultMessage: 'Boundary index pattern title is required.', + }) + ); + } + + if (!boundaryGeoField) { + errors.boundaryGeoField.push( + i18n.translate('xpack.stackAlerts.geoContainment.error.requiredBoundaryGeoFieldText', { + defaultMessage: 'Boundary geo field is required.', + }) + ); + } + + return validationResult; +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/index.ts index 61cf7193fedb7..9d611aefb738b 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/index.ts @@ -5,6 +5,7 @@ */ import { getAlertType as getGeoThresholdAlertType } from './geo_threshold'; +import { getAlertType as getGeoContainmentAlertType } from './geo_containment'; import { getAlertType as getThresholdAlertType } from './threshold'; import { Config } from '../../common'; import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; @@ -16,8 +17,9 @@ export function registerAlertTypes({ alertTypeRegistry: TriggersAndActionsUIPublicPluginSetup['alertTypeRegistry']; config: Config; }) { - if (config.enableGeoTrackingThresholdAlert) { + if (config.enableGeoAlerts) { alertTypeRegistry.register(getGeoThresholdAlertType()); + alertTypeRegistry.register(getGeoContainmentAlertType()); } alertTypeRegistry.register(getThresholdAlertType()); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts new file mode 100644 index 0000000000000..a873cab69f23b --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { Logger } from 'src/core/server'; +import { STACK_ALERTS_FEATURE_ID } from '../../../common'; +import { getGeoContainmentExecutor } from './geo_containment'; +import { + ActionGroup, + AlertServices, + ActionVariable, + AlertTypeState, +} from '../../../../alerts/server'; +import { Query } from '../../../../../../src/plugins/data/common/query'; + +export const GEO_CONTAINMENT_ID = '.geo-containment'; +export const ActionGroupId = 'Tracked entity contained'; + +const actionVariableContextEntityIdLabel = i18n.translate( + 'xpack.stackAlerts.geoContainment.actionVariableContextEntityIdLabel', + { + defaultMessage: 'The entity ID of the document that triggered the alert', + } +); + +const actionVariableContextEntityDateTimeLabel = i18n.translate( + 'xpack.stackAlerts.geoContainment.actionVariableContextFromEntityDateTimeLabel', + { + defaultMessage: `The date the entity was recorded in the boundary`, + } +); + +const actionVariableContextEntityDocumentIdLabel = i18n.translate( + 'xpack.stackAlerts.geoContainment.actionVariableContextFromEntityDocumentIdLabel', + { + defaultMessage: 'The id of the contained entity document', + } +); + +const actionVariableContextDetectionDateTimeLabel = i18n.translate( + 'xpack.stackAlerts.geoContainment.actionVariableContextDetectionDateTimeLabel', + { + defaultMessage: 'The alert interval end time this change was recorded', + } +); + +const actionVariableContextEntityLocationLabel = i18n.translate( + 'xpack.stackAlerts.geoContainment.actionVariableContextFromEntityLocationLabel', + { + defaultMessage: 'The location of the entity', + } +); + +const actionVariableContextContainingBoundaryIdLabel = i18n.translate( + 'xpack.stackAlerts.geoContainment.actionVariableContextContainingBoundaryIdLabel', + { + defaultMessage: 'The id of the boundary containing the entity', + } +); + +const actionVariableContextContainingBoundaryNameLabel = i18n.translate( + 'xpack.stackAlerts.geoContainment.actionVariableContextContainingBoundaryNameLabel', + { + defaultMessage: 'The boundary the entity is currently located within', + } +); + +const actionVariables = { + context: [ + // Alert-specific data + { name: 'entityId', description: actionVariableContextEntityIdLabel }, + { name: 'entityDateTime', description: actionVariableContextEntityDateTimeLabel }, + { name: 'entityDocumentId', description: actionVariableContextEntityDocumentIdLabel }, + { name: 'detectionDateTime', description: actionVariableContextDetectionDateTimeLabel }, + { name: 'entityLocation', description: actionVariableContextEntityLocationLabel }, + { name: 'containingBoundaryId', description: actionVariableContextContainingBoundaryIdLabel }, + { + name: 'containingBoundaryName', + description: actionVariableContextContainingBoundaryNameLabel, + }, + ], +}; + +export const ParamsSchema = schema.object({ + index: schema.string({ minLength: 1 }), + indexId: schema.string({ minLength: 1 }), + geoField: schema.string({ minLength: 1 }), + entity: schema.string({ minLength: 1 }), + dateField: schema.string({ minLength: 1 }), + boundaryType: schema.string({ minLength: 1 }), + boundaryIndexTitle: schema.string({ minLength: 1 }), + boundaryIndexId: schema.string({ minLength: 1 }), + boundaryGeoField: schema.string({ minLength: 1 }), + boundaryNameField: schema.maybe(schema.string({ minLength: 1 })), + delayOffsetWithUnits: schema.maybe(schema.string({ minLength: 1 })), + indexQuery: schema.maybe(schema.any({})), + boundaryIndexQuery: schema.maybe(schema.any({})), +}); + +export interface GeoContainmentParams { + index: string; + indexId: string; + geoField: string; + entity: string; + dateField: string; + boundaryType: string; + boundaryIndexTitle: string; + boundaryIndexId: string; + boundaryGeoField: string; + boundaryNameField?: string; + delayOffsetWithUnits?: string; + indexQuery?: Query; + boundaryIndexQuery?: Query; +} + +export function getAlertType( + logger: Logger +): { + defaultActionGroupId: string; + actionGroups: ActionGroup[]; + executor: ({ + previousStartedAt: currIntervalStartTime, + startedAt: currIntervalEndTime, + services, + params, + alertId, + state, + }: { + previousStartedAt: Date | null; + startedAt: Date; + services: AlertServices; + params: GeoContainmentParams; + alertId: string; + state: AlertTypeState; + }) => Promise; + validate?: { + params?: { + validate: (object: unknown) => GeoContainmentParams; + }; + }; + name: string; + producer: string; + id: string; + actionVariables?: { + context?: ActionVariable[]; + state?: ActionVariable[]; + params?: ActionVariable[]; + }; +} { + const alertTypeName = i18n.translate('xpack.stackAlerts.geoContainment.alertTypeTitle', { + defaultMessage: 'Geo tracking containment', + }); + + const actionGroupName = i18n.translate( + 'xpack.stackAlerts.geoContainment.actionGroupContainmentMetTitle', + { + defaultMessage: 'Tracking containment met', + } + ); + + return { + id: GEO_CONTAINMENT_ID, + name: alertTypeName, + actionGroups: [{ id: ActionGroupId, name: actionGroupName }], + defaultActionGroupId: ActionGroupId, + executor: getGeoContainmentExecutor(logger), + producer: STACK_ALERTS_FEATURE_ID, + validate: { + params: ParamsSchema, + }, + actionVariables, + }; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/es_query_builder.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/es_query_builder.ts new file mode 100644 index 0000000000000..02ac19e7b6f1e --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/es_query_builder.ts @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILegacyScopedClusterClient } from 'kibana/server'; +import { SearchResponse } from 'elasticsearch'; +import { Logger } from 'src/core/server'; +import { + Query, + IIndexPattern, + fromKueryExpression, + toElasticsearchQuery, + luceneStringToDsl, +} from '../../../../../../src/plugins/data/common'; + +export const OTHER_CATEGORY = 'other'; +// Consider dynamically obtaining from config? +const MAX_TOP_LEVEL_QUERY_SIZE = 0; +const MAX_SHAPES_QUERY_SIZE = 10000; +const MAX_BUCKETS_LIMIT = 65535; + +export const getEsFormattedQuery = (query: Query, indexPattern?: IIndexPattern) => { + let esFormattedQuery; + + const queryLanguage = query.language; + if (queryLanguage === 'kuery') { + const ast = fromKueryExpression(query.query); + esFormattedQuery = toElasticsearchQuery(ast, indexPattern); + } else { + esFormattedQuery = luceneStringToDsl(query.query); + } + return esFormattedQuery; +}; + +export async function getShapesFilters( + boundaryIndexTitle: string, + boundaryGeoField: string, + geoField: string, + callCluster: ILegacyScopedClusterClient['callAsCurrentUser'], + log: Logger, + alertId: string, + boundaryNameField?: string, + boundaryIndexQuery?: Query +) { + const filters: Record = {}; + const shapesIdsNamesMap: Record = {}; + // Get all shapes in index + const boundaryData: SearchResponse> = await callCluster('search', { + index: boundaryIndexTitle, + body: { + size: MAX_SHAPES_QUERY_SIZE, + ...(boundaryIndexQuery ? { query: getEsFormattedQuery(boundaryIndexQuery) } : {}), + }, + }); + + boundaryData.hits.hits.forEach(({ _index, _id }) => { + filters[_id] = { + geo_shape: { + [geoField]: { + indexed_shape: { + index: _index, + id: _id, + path: boundaryGeoField, + }, + }, + }, + }; + }); + if (boundaryNameField) { + boundaryData.hits.hits.forEach( + ({ _source, _id }: { _source: Record; _id: string }) => { + shapesIdsNamesMap[_id] = _source[boundaryNameField]; + } + ); + } + return { + shapesFilters: filters, + shapesIdsNamesMap, + }; +} + +export async function executeEsQueryFactory( + { + entity, + index, + dateField, + boundaryGeoField, + geoField, + boundaryIndexTitle, + indexQuery, + }: { + entity: string; + index: string; + dateField: string; + boundaryGeoField: string; + geoField: string; + boundaryIndexTitle: string; + boundaryNameField?: string; + indexQuery?: Query; + }, + { callCluster }: { callCluster: ILegacyScopedClusterClient['callAsCurrentUser'] }, + log: Logger, + shapesFilters: Record +) { + return async ( + gteDateTime: Date | null, + ltDateTime: Date | null + ): Promise | undefined> => { + let esFormattedQuery; + if (indexQuery) { + const gteEpochDateTime = gteDateTime ? new Date(gteDateTime).getTime() : null; + const ltEpochDateTime = ltDateTime ? new Date(ltDateTime).getTime() : null; + const dateRangeUpdatedQuery = + indexQuery.language === 'kuery' + ? `(${dateField} >= "${gteEpochDateTime}" and ${dateField} < "${ltEpochDateTime}") and (${indexQuery.query})` + : `(${dateField}:[${gteDateTime} TO ${ltDateTime}]) AND (${indexQuery.query})`; + esFormattedQuery = getEsFormattedQuery({ + query: dateRangeUpdatedQuery, + language: indexQuery.language, + }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const esQuery: Record = { + index, + body: { + size: MAX_TOP_LEVEL_QUERY_SIZE, + aggs: { + shapes: { + filters: { + other_bucket_key: OTHER_CATEGORY, + filters: shapesFilters, + }, + aggs: { + entitySplit: { + terms: { + size: MAX_BUCKETS_LIMIT / ((Object.keys(shapesFilters).length || 1) * 2), + field: entity, + }, + aggs: { + entityHits: { + top_hits: { + size: 1, + sort: [ + { + [dateField]: { + order: 'desc', + }, + }, + ], + docvalue_fields: [entity, dateField, geoField], + _source: false, + }, + }, + }, + }, + }, + }, + }, + query: esFormattedQuery + ? esFormattedQuery + : { + bool: { + must: [], + filter: [ + { + match_all: {}, + }, + { + range: { + [dateField]: { + ...(gteDateTime ? { gte: gteDateTime } : {}), + lt: ltDateTime, // 'less than' to prevent overlap between intervals + format: 'strict_date_optional_time', + }, + }, + }, + ], + should: [], + must_not: [], + }, + }, + stored_fields: ['*'], + docvalue_fields: [ + { + field: dateField, + format: 'date_time', + }, + ], + }, + }; + + let esResult: SearchResponse | undefined; + try { + esResult = await callCluster('search', esQuery); + } catch (err) { + log.warn(`${err.message}`); + } + return esResult; + }; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts new file mode 100644 index 0000000000000..8330c4f6bf678 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { SearchResponse } from 'elasticsearch'; +import { Logger } from 'src/core/server'; +import { executeEsQueryFactory, getShapesFilters, OTHER_CATEGORY } from './es_query_builder'; +import { AlertServices, AlertTypeState } from '../../../../alerts/server'; +import { ActionGroupId, GEO_CONTAINMENT_ID, GeoContainmentParams } from './alert_type'; + +interface LatestEntityLocation { + location: number[]; + shapeLocationId: string; + dateInShape: string | null; + docId: string; +} + +// Flatten agg results and get latest locations for each entity +export function transformResults( + results: SearchResponse | undefined, + dateField: string, + geoField: string +): Map { + if (!results) { + return new Map(); + } + const buckets = _.get(results, 'aggregations.shapes.buckets', {}); + const arrResults = _.flatMap(buckets, (bucket: unknown, bucketKey: string) => { + const subBuckets = _.get(bucket, 'entitySplit.buckets', []); + return _.map(subBuckets, (subBucket) => { + const locationFieldResult = _.get( + subBucket, + `entityHits.hits.hits[0].fields["${geoField}"][0]`, + '' + ); + const location = locationFieldResult + ? _.chain(locationFieldResult) + .split(', ') + .map((coordString) => +coordString) + .reverse() + .value() + : []; + const dateInShape = _.get( + subBucket, + `entityHits.hits.hits[0].fields["${dateField}"][0]`, + null + ); + const docId = _.get(subBucket, `entityHits.hits.hits[0]._id`); + + return { + location, + shapeLocationId: bucketKey, + entityName: subBucket.key, + dateInShape, + docId, + }; + }); + }); + const orderedResults = _.orderBy(arrResults, ['entityName', 'dateInShape'], ['asc', 'desc']) + // Get unique + .reduce( + ( + accu: Map, + el: LatestEntityLocation & { entityName: string } + ) => { + const { entityName, ...locationData } = el; + if (!accu.has(entityName)) { + accu.set(entityName, locationData); + } + return accu; + }, + new Map() + ); + return orderedResults; +} + +function getOffsetTime(delayOffsetWithUnits: string, oldTime: Date): Date { + const timeUnit = delayOffsetWithUnits.slice(-1); + const time: number = +delayOffsetWithUnits.slice(0, -1); + + const adjustedDate = new Date(oldTime.getTime()); + if (timeUnit === 's') { + adjustedDate.setSeconds(adjustedDate.getSeconds() - time); + } else if (timeUnit === 'm') { + adjustedDate.setMinutes(adjustedDate.getMinutes() - time); + } else if (timeUnit === 'h') { + adjustedDate.setHours(adjustedDate.getHours() - time); + } else if (timeUnit === 'd') { + adjustedDate.setDate(adjustedDate.getDate() - time); + } + return adjustedDate; +} + +export const getGeoContainmentExecutor = (log: Logger) => + async function ({ + previousStartedAt, + startedAt, + services, + params, + alertId, + state, + }: { + previousStartedAt: Date | null; + startedAt: Date; + services: AlertServices; + params: GeoContainmentParams; + alertId: string; + state: AlertTypeState; + }): Promise { + const { shapesFilters, shapesIdsNamesMap } = state.shapesFilters + ? state + : await getShapesFilters( + params.boundaryIndexTitle, + params.boundaryGeoField, + params.geoField, + services.callCluster, + log, + alertId, + params.boundaryNameField, + params.boundaryIndexQuery + ); + + const executeEsQuery = await executeEsQueryFactory(params, services, log, shapesFilters); + + let currIntervalStartTime = previousStartedAt; + let currIntervalEndTime = startedAt; + if (params.delayOffsetWithUnits) { + if (currIntervalStartTime) { + currIntervalStartTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalStartTime); + } + currIntervalEndTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalEndTime); + } + + // Start collecting data only on the first cycle + let currentIntervalResults: SearchResponse | undefined; + if (!currIntervalStartTime) { + log.debug(`alert ${GEO_CONTAINMENT_ID}:${alertId} alert initialized. Collecting data`); + // Consider making first time window configurable? + const START_TIME_WINDOW = 1; + const tempPreviousEndTime = new Date(currIntervalEndTime); + tempPreviousEndTime.setMinutes(tempPreviousEndTime.getMinutes() - START_TIME_WINDOW); + currentIntervalResults = await executeEsQuery(tempPreviousEndTime, currIntervalEndTime); + } else { + currentIntervalResults = await executeEsQuery(currIntervalStartTime, currIntervalEndTime); + } + + const currLocationMap: Map = transformResults( + currentIntervalResults, + params.dateField, + params.geoField + ); + + // Cycle through new alert statuses and set active + currLocationMap.forEach(({ location, shapeLocationId, dateInShape, docId }, entityName) => { + const containingBoundaryName = shapesIdsNamesMap[shapeLocationId] || shapeLocationId; + const context = { + entityId: entityName, + entityDateTime: new Date(currIntervalEndTime).toISOString(), + entityDocumentId: docId, + detectionDateTime: new Date(currIntervalEndTime).toISOString(), + entityLocation: `POINT (${location[0]} ${location[1]})`, + containingBoundaryId: shapeLocationId, + containingBoundaryName, + }; + const alertInstanceId = `${entityName}-${containingBoundaryName}`; + if (shapeLocationId !== OTHER_CATEGORY) { + services.alertInstanceFactory(alertInstanceId).scheduleActions(ActionGroupId, context); + } + }); + + return { + shapesFilters, + shapesIdsNamesMap, + }; + }; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts new file mode 100644 index 0000000000000..2fa2bed9d8419 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger } from 'src/core/server'; +import { AlertingSetup } from '../../types'; +import { getAlertType } from './alert_type'; + +interface RegisterParams { + logger: Logger; + alerts: AlertingSetup; +} + +export function register(params: RegisterParams) { + const { logger, alerts } = params; + alerts.registerType(getAlertType(logger)); +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/__snapshots__/alert_type.test.ts.snap b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/__snapshots__/alert_type.test.ts.snap new file mode 100644 index 0000000000000..e11ad33f7c753 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/__snapshots__/alert_type.test.ts.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`alertType alert type creation structure is the expected value 1`] = ` +Object { + "context": Array [ + Object { + "description": "The entity ID of the document that triggered the alert", + "name": "entityId", + }, + Object { + "description": "The date the entity was recorded in the boundary", + "name": "entityDateTime", + }, + Object { + "description": "The id of the contained entity document", + "name": "entityDocumentId", + }, + Object { + "description": "The alert interval end time this change was recorded", + "name": "detectionDateTime", + }, + Object { + "description": "The location of the entity", + "name": "entityLocation", + }, + Object { + "description": "The id of the boundary containing the entity", + "name": "containingBoundaryId", + }, + Object { + "description": "The boundary the entity is currently located within", + "name": "containingBoundaryName", + }, + ], +} +`; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.test.ts new file mode 100644 index 0000000000000..f3dc3855eb91b --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loggingSystemMock } from '../../../../../../../src/core/server/mocks'; +import { getAlertType, GeoContainmentParams } from '../alert_type'; + +describe('alertType', () => { + const logger = loggingSystemMock.create().get(); + + const alertType = getAlertType(logger); + + it('alert type creation structure is the expected value', async () => { + expect(alertType.id).toBe('.geo-containment'); + expect(alertType.name).toBe('Geo tracking containment'); + expect(alertType.actionGroups).toEqual([ + { id: 'Tracked entity contained', name: 'Tracking containment met' }, + ]); + + expect(alertType.actionVariables).toMatchSnapshot(); + }); + + it('validator succeeds with valid params', async () => { + const params: GeoContainmentParams = { + index: 'testIndex', + indexId: 'testIndexId', + geoField: 'testField', + entity: 'testField', + dateField: 'testField', + boundaryType: 'testType', + boundaryIndexTitle: 'testIndex', + boundaryIndexId: 'testIndex', + boundaryGeoField: 'testField', + boundaryNameField: 'testField', + delayOffsetWithUnits: 'testOffset', + }; + + expect(alertType.validate?.params?.validate(params)).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/es_query_builder.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/es_query_builder.test.ts new file mode 100644 index 0000000000000..d577a88e8e2f8 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/es_query_builder.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getEsFormattedQuery } from '../es_query_builder'; + +describe('esFormattedQuery', () => { + it('lucene queries are converted correctly', async () => { + const testLuceneQuery1 = { + query: `"airport": "Denver"`, + language: 'lucene', + }; + const esFormattedQuery1 = getEsFormattedQuery(testLuceneQuery1); + expect(esFormattedQuery1).toStrictEqual({ query_string: { query: '"airport": "Denver"' } }); + const testLuceneQuery2 = { + query: `title:"Fun with turnips" AND text:Cabbage, cabbage and more cabbage!`, + language: 'lucene', + }; + const esFormattedQuery2 = getEsFormattedQuery(testLuceneQuery2); + expect(esFormattedQuery2).toStrictEqual({ + query_string: { + query: `title:"Fun with turnips" AND text:Cabbage, cabbage and more cabbage!`, + }, + }); + }); + + it('kuery queries are converted correctly', async () => { + const testKueryQuery1 = { + query: `"airport": "Denver"`, + language: 'kuery', + }; + const esFormattedQuery1 = getEsFormattedQuery(testKueryQuery1); + expect(esFormattedQuery1).toStrictEqual({ + bool: { minimum_should_match: 1, should: [{ match_phrase: { airport: 'Denver' } }] }, + }); + const testKueryQuery2 = { + query: `"airport": "Denver" and ("animal": "goat" or "animal": "narwhal")`, + language: 'kuery', + }; + const esFormattedQuery2 = getEsFormattedQuery(testKueryQuery2); + expect(esFormattedQuery2).toStrictEqual({ + bool: { + filter: [ + { bool: { should: [{ match_phrase: { airport: 'Denver' } }], minimum_should_match: 1 } }, + { + bool: { + should: [ + { + bool: { should: [{ match_phrase: { animal: 'goat' } }], minimum_should_match: 1 }, + }, + { + bool: { + should: [{ match_phrase: { animal: 'narwhal' } }], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/es_sample_response.json b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/es_sample_response.json new file mode 100644 index 0000000000000..70edbd09aa5a1 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/es_sample_response.json @@ -0,0 +1,170 @@ +{ + "took" : 2760, + "timed_out" : false, + "_shards" : { + "total" : 1, + "successful" : 1, + "skipped" : 0, + "failed" : 0 + }, + "hits" : { + "total" : { + "value" : 10000, + "relation" : "gte" + }, + "max_score" : 0.0, + "hits" : [] + }, + "aggregations" : { + "shapes" : { + "meta" : { }, + "buckets" : { + "0DrJu3QB6yyY-xQxv6Ip" : { + "doc_count" : 1047, + "entitySplit" : { + "doc_count_error_upper_bound" : 0, + "sum_other_doc_count" : 957, + "buckets" : [ + { + "key" : "936", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "N-ng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:41.190Z" + ], + "location" : [ + "40.62806099653244, -82.8814151789993" + ], + "entity_id" : [ + "936" + ] + }, + "sort" : [ + 1601316101190 + ] + } + ] + } + } + }, + { + "key" : "AAL2019", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "iOng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:41.191Z" + ], + "location" : [ + "39.006176185794175, -82.22068064846098" + ], + "entity_id" : [ + "AAL2019" + ] + }, + "sort" : [ + 1601316101191 + ] + } + ] + } + } + }, + { + "key" : "AAL2323", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "n-ng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:41.191Z" + ], + "location" : [ + "41.6677269525826, -84.71324851736426" + ], + "entity_id" : [ + "AAL2323" + ] + }, + "sort" : [ + 1601316101191 + ] + } + ] + } + } + }, + { + "key" : "ABD5250", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "GOng1XQB6yyY-xQxnGWM", + "_score" : null, + "fields" : { + "@timestamp" : [ + "2020-09-28T18:01:41.192Z" + ], + "location" : [ + "39.07997465226799, 6.073727197945118" + ], + "entity_id" : [ + "ABD5250" + ] + }, + "sort" : [ + 1601316101192 + ] + } + ] + } + } + } + ] + } + } + } + } + } +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/es_sample_response_with_nesting.json b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/es_sample_response_with_nesting.json new file mode 100644 index 0000000000000..a4b7b6872b341 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/es_sample_response_with_nesting.json @@ -0,0 +1,170 @@ +{ + "took" : 2760, + "timed_out" : false, + "_shards" : { + "total" : 1, + "successful" : 1, + "skipped" : 0, + "failed" : 0 + }, + "hits" : { + "total" : { + "value" : 10000, + "relation" : "gte" + }, + "max_score" : 0.0, + "hits" : [] + }, + "aggregations" : { + "shapes" : { + "meta" : { }, + "buckets" : { + "0DrJu3QB6yyY-xQxv6Ip" : { + "doc_count" : 1047, + "entitySplit" : { + "doc_count_error_upper_bound" : 0, + "sum_other_doc_count" : 957, + "buckets" : [ + { + "key" : "936", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "N-ng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "time_data.@timestamp" : [ + "2020-09-28T18:01:41.190Z" + ], + "geo.coords.location" : [ + "40.62806099653244, -82.8814151789993" + ], + "entity_id" : [ + "936" + ] + }, + "sort" : [ + 1601316101190 + ] + } + ] + } + } + }, + { + "key" : "AAL2019", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "iOng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "time_data.@timestamp" : [ + "2020-09-28T18:01:41.191Z" + ], + "geo.coords.location" : [ + "39.006176185794175, -82.22068064846098" + ], + "entity_id" : [ + "AAL2019" + ] + }, + "sort" : [ + 1601316101191 + ] + } + ] + } + } + }, + { + "key" : "AAL2323", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "n-ng1XQB6yyY-xQxnGSM", + "_score" : null, + "fields" : { + "time_data.@timestamp" : [ + "2020-09-28T18:01:41.191Z" + ], + "geo.coords.location" : [ + "41.6677269525826, -84.71324851736426" + ], + "entity_id" : [ + "AAL2323" + ] + }, + "sort" : [ + 1601316101191 + ] + } + ] + } + } + }, + { + "key" : "ABD5250", + "doc_count" : 9, + "entityHits" : { + "hits" : { + "total" : { + "value" : 9, + "relation" : "eq" + }, + "max_score" : null, + "hits" : [ + { + "_index" : "flight_tracks", + "_id" : "GOng1XQB6yyY-xQxnGWM", + "_score" : null, + "fields" : { + "time_data.@timestamp" : [ + "2020-09-28T18:01:41.192Z" + ], + "geo.coords.location" : [ + "39.07997465226799, 6.073727197945118" + ], + "entity_id" : [ + "ABD5250" + ] + }, + "sort" : [ + 1601316101192 + ] + } + ] + } + } + } + ] + } + } + } + } + } +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts new file mode 100644 index 0000000000000..44c9aec1aae9e --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import sampleJsonResponse from './es_sample_response.json'; +import sampleJsonResponseWithNesting from './es_sample_response_with_nesting.json'; +import { transformResults } from '../geo_containment'; +import { SearchResponse } from 'elasticsearch'; + +describe('geo_containment', () => { + describe('transformResults', () => { + const dateField = '@timestamp'; + const geoField = 'location'; + it('should correctly transform expected results', async () => { + const transformedResults = transformResults( + (sampleJsonResponse as unknown) as SearchResponse, + dateField, + geoField + ); + expect(transformedResults).toEqual( + new Map([ + [ + '936', + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], + [ + 'AAL2019', + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'iOng1XQB6yyY-xQxnGSM', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], + [ + 'AAL2323', + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'n-ng1XQB6yyY-xQxnGSM', + location: [-84.71324851736426, 41.6677269525826], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], + [ + 'ABD5250', + { + dateInShape: '2020-09-28T18:01:41.192Z', + docId: 'GOng1XQB6yyY-xQxnGWM', + location: [6.073727197945118, 39.07997465226799], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], + ]) + ); + }); + + const nestedDateField = 'time_data.@timestamp'; + const nestedGeoField = 'geo.coords.location'; + it('should correctly transform expected results if fields are nested', async () => { + const transformedResults = transformResults( + (sampleJsonResponseWithNesting as unknown) as SearchResponse, + nestedDateField, + nestedGeoField + ); + expect(transformedResults).toEqual( + new Map([ + [ + '936', + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], + [ + 'AAL2019', + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'iOng1XQB6yyY-xQxnGSM', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], + [ + 'AAL2323', + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'n-ng1XQB6yyY-xQxnGSM', + location: [-84.71324851736426, 41.6677269525826], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], + [ + 'ABD5250', + { + dateInShape: '2020-09-28T18:01:41.192Z', + docId: 'GOng1XQB6yyY-xQxnGWM', + location: [6.073727197945118, 39.07997465226799], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], + ]) + ); + }); + + it('should return an empty array if no results', async () => { + const transformedResults = transformResults(undefined, dateField, geoField); + expect(transformedResults).toEqual(new Map()); + }); + }); +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/index.ts index 461358d1296e2..21a7ffc481323 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index.ts @@ -8,6 +8,7 @@ import { Logger } from 'src/core/server'; import { AlertingSetup, StackAlertsStartDeps } from '../types'; import { register as registerIndexThreshold } from './index_threshold'; import { register as registerGeoThreshold } from './geo_threshold'; +import { register as registerGeoContainment } from './geo_containment'; interface RegisterAlertTypesParams { logger: Logger; @@ -18,4 +19,5 @@ interface RegisterAlertTypesParams { export function registerBuiltInAlertTypes(params: RegisterAlertTypesParams) { registerIndexThreshold(params); registerGeoThreshold(params); + registerGeoContainment(params); } diff --git a/x-pack/plugins/stack_alerts/server/index.ts b/x-pack/plugins/stack_alerts/server/index.ts index adb617558e6f4..3ef8db33983de 100644 --- a/x-pack/plugins/stack_alerts/server/index.ts +++ b/x-pack/plugins/stack_alerts/server/index.ts @@ -11,13 +11,13 @@ export { ID as INDEX_THRESHOLD_ID } from './alert_types/index_threshold/alert_ty export const config: PluginConfigDescriptor = { exposeToBrowser: { - enableGeoTrackingThresholdAlert: true, + enableGeoAlerts: true, }, schema: configSchema, deprecations: ({ renameFromRoot }) => [ renameFromRoot( 'xpack.triggers_actions_ui.enableGeoTrackingThresholdAlert', - 'xpack.stack_alerts.enableGeoTrackingThresholdAlert' + 'xpack.stack_alerts.enableGeoAlerts' ), ], }; diff --git a/x-pack/plugins/stack_alerts/server/plugin.test.ts b/x-pack/plugins/stack_alerts/server/plugin.test.ts index 71972707852fe..3037504ed3e39 100644 --- a/x-pack/plugins/stack_alerts/server/plugin.test.ts +++ b/x-pack/plugins/stack_alerts/server/plugin.test.ts @@ -27,7 +27,7 @@ describe('AlertingBuiltins Plugin', () => { const featuresSetup = featuresPluginMock.createSetup(); await plugin.setup(coreSetup, { alerts: alertingSetup, features: featuresSetup }); - expect(alertingSetup.registerType).toHaveBeenCalledTimes(2); + expect(alertingSetup.registerType).toHaveBeenCalledTimes(3); const indexThresholdArgs = alertingSetup.registerType.mock.calls[0][0]; const testedIndexThresholdArgs = { diff --git a/x-pack/test/functional/apps/lens/rollup.ts b/x-pack/test/functional/apps/lens/rollup.ts index f6882c8aed214..7c04c34ea7603 100644 --- a/x-pack/test/functional/apps/lens/rollup.ts +++ b/x-pack/test/functional/apps/lens/rollup.ts @@ -24,7 +24,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.unload('lens/rollup/config'); }); - it('should allow creation of lens xy chart', async () => { + it.skip('should allow creation of lens xy chart', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); await PageObjects.lens.goToTimeRange(); diff --git a/x-pack/test/functional/apps/rollup_job/rollup_jobs.js b/x-pack/test/functional/apps/rollup_job/rollup_jobs.js index 5b6484d7184f3..f7f92e6955799 100644 --- a/x-pack/test/functional/apps/rollup_job/rollup_jobs.js +++ b/x-pack/test/functional/apps/rollup_job/rollup_jobs.js @@ -11,7 +11,8 @@ import { mockIndices } from './hybrid_index_helper'; export default function ({ getService, getPageObjects }) { const es = getService('legacyEs'); const esArchiver = getService('esArchiver'); - const PageObjects = getPageObjects(['rollup', 'common']); + const PageObjects = getPageObjects(['rollup', 'common', 'security']); + const security = getService('security'); describe('rollup job', function () { //Since rollups can only be created once with the same name (even if you delete it), @@ -20,6 +21,7 @@ export default function ({ getService, getPageObjects }) { const targetIndexName = 'rollup-to-be'; const rollupSourceIndexPattern = 'to-be*'; const rollupSourceDataPrepend = 'to-be'; + //make sure all dates have the same concept of "now" const now = new Date(); const pastDates = [ @@ -27,6 +29,10 @@ export default function ({ getService, getPageObjects }) { datemath.parse('now-2d', { forceNow: now }), datemath.parse('now-3d', { forceNow: now }), ]; + before(async () => { + await security.testUser.setRoles(['manage_rollups_role']); + await PageObjects.common.navigateToApp('rollupJob'); + }); it('create new rollup job', async () => { const interval = '1000ms'; @@ -35,7 +41,6 @@ export default function ({ getService, getPageObjects }) { await es.index(mockIndices(day, rollupSourceDataPrepend)); } - await PageObjects.common.navigateToApp('rollupJob'); await PageObjects.rollup.createNewRollUpJob( rollupJobName, rollupSourceIndexPattern, @@ -66,6 +71,7 @@ export default function ({ getService, getPageObjects }) { await es.indices.delete({ index: targetIndexName }); await es.indices.delete({ index: rollupSourceIndexPattern }); await esArchiver.load('empty_kibana'); + await security.testUser.restoreDefaults(); }); }); } diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index e3f83f08eb758..ddd30bc631995 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -414,6 +414,25 @@ export default async function ({ readConfigFile }) { }, ], }, + manage_rollups_role: { + elasticsearch: { + cluster: ['manage', 'manage_rollup'], + indices: [ + { + names: ['*'], + privileges: ['read', 'delete', 'create_index', 'view_index_metadata'], + }, + ], + }, + kibana: [ + { + feature: { + discover: ['read'], + }, + spaces: ['*'], + }, + ], + }, //Kibana feature privilege isn't specific to advancedSetting. It can be anything. https://github.com/elastic/kibana/issues/35965 test_api_keys: {