From e559a612bcfb6a483fda1c3ff128eec69a749e8b Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 24 Nov 2020 00:17:54 +0000 Subject: [PATCH 01/89] chore(NA): skip docker build if docker binary is not available (#84154) --- .ci/build_docker.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.ci/build_docker.sh b/.ci/build_docker.sh index 1f45182aad84..07013f13cdae 100755 --- a/.ci/build_docker.sh +++ b/.ci/build_docker.sh @@ -7,4 +7,8 @@ cd "$(dirname "${0}")" cp /usr/local/bin/runbld ./ cp /usr/local/bin/bash_standard_lib.sh ./ -docker build -t kibana-ci -f ./Dockerfile . +if which docker >/dev/null; then + docker build -t kibana-ci -f ./Dockerfile . +else + echo "Docker binary is not available. Skipping the docker build this time." +fi From ba7a8723ed53c5071adf4dc2d59f1e1078c5eccb Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Mon, 23 Nov 2020 20:29:56 -0500 Subject: [PATCH 02/89] [Maps] Fix term-join creation (#83974) --- .../maps/public/classes/joins/inner_join.d.ts | 44 ------------ .../joins/{inner_join.js => inner_join.ts} | 71 ++++++++++++------- .../plugins/maps/public/classes/joins/join.ts | 40 ----------- .../layers/vector_layer/vector_layer.tsx | 25 ++++--- .../sources/es_term_source/es_term_source.ts | 5 +- .../properties/dynamic_style_property.tsx | 4 +- .../classes/tooltips/join_tooltip_property.ts | 6 +- .../maps/public/selectors/map_selectors.ts | 7 +- 8 files changed, 71 insertions(+), 131 deletions(-) delete mode 100644 x-pack/plugins/maps/public/classes/joins/inner_join.d.ts rename x-pack/plugins/maps/public/classes/joins/{inner_join.js => inner_join.ts} (54%) delete mode 100644 x-pack/plugins/maps/public/classes/joins/join.ts diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.d.ts b/x-pack/plugins/maps/public/classes/joins/inner_join.d.ts deleted file mode 100644 index 987e7bc93c2f..000000000000 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Feature, GeoJsonProperties } from 'geojson'; -import { ESTermSource } from '../sources/es_term_source'; -import { IJoin } from './join'; -import { JoinDescriptor } from '../../../common/descriptor_types'; -import { ISource } from '../sources/source'; -import { ITooltipProperty } from '../tooltips/tooltip_property'; -import { IField } from '../fields/field'; -import { PropertiesMap } from '../../../common/elasticsearch_util'; - -export class InnerJoin implements IJoin { - constructor(joinDescriptor: JoinDescriptor, leftSource: ISource); - - destroy: () => void; - - getRightJoinSource(): ESTermSource; - - toDescriptor(): JoinDescriptor; - - getJoinFields: () => IField[]; - - getLeftField: () => IField; - - getIndexPatternIds: () => string[]; - - getQueryableIndexPatternIds: () => string[]; - - getSourceDataRequestId: () => string; - - getSourceMetaDataRequestId(): string; - - getSourceFormattersDataRequestId(): string; - - getTooltipProperties(properties: GeoJsonProperties): Promise; - - hasCompleteConfig: () => boolean; - - joinPropertiesToFeature: (feature: Feature, propertiesMap?: PropertiesMap) => boolean; -} diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.js b/x-pack/plugins/maps/public/classes/joins/inner_join.ts similarity index 54% rename from x-pack/plugins/maps/public/classes/joins/inner_join.js rename to x-pack/plugins/maps/public/classes/joins/inner_join.ts index 75bf59d9d640..32bd767aa94d 100644 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.js +++ b/x-pack/plugins/maps/public/classes/joins/inner_join.ts @@ -4,44 +4,58 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Query } from 'src/plugins/data/public'; +import { Feature, GeoJsonProperties } from 'geojson'; import { ESTermSource } from '../sources/es_term_source'; import { getComputedFieldNamePrefix } from '../styles/vector/style_util'; import { META_DATA_REQUEST_ID_SUFFIX, FORMATTERS_DATA_REQUEST_ID_SUFFIX, } from '../../../common/constants'; +import { JoinDescriptor } from '../../../common/descriptor_types'; +import { IVectorSource } from '../sources/vector_source'; +import { IField } from '../fields/field'; +import { PropertiesMap } from '../../../common/elasticsearch_util'; export class InnerJoin { - constructor(joinDescriptor, leftSource) { + private readonly _descriptor: JoinDescriptor; + private readonly _rightSource?: ESTermSource; + private readonly _leftField?: IField; + + constructor(joinDescriptor: JoinDescriptor, leftSource: IVectorSource) { this._descriptor = joinDescriptor; const inspectorAdapters = leftSource.getInspectorAdapters(); - this._rightSource = new ESTermSource(joinDescriptor.right, inspectorAdapters); - this._leftField = this._descriptor.leftField + if ( + joinDescriptor.right && + 'indexPatternId' in joinDescriptor.right && + 'term' in joinDescriptor.right + ) { + this._rightSource = new ESTermSource(joinDescriptor.right, inspectorAdapters); + } + this._leftField = joinDescriptor.leftField ? leftSource.createField({ fieldName: joinDescriptor.leftField }) - : null; + : undefined; } destroy() { - this._rightSource.destroy(); + if (this._rightSource) { + this._rightSource.destroy(); + } } hasCompleteConfig() { - if (this._leftField && this._rightSource) { - return this._rightSource.hasCompleteConfig(); - } - - return false; + return this._leftField && this._rightSource ? this._rightSource.hasCompleteConfig() : false; } getJoinFields() { - return this._rightSource.getMetricFields(); + return this._rightSource ? this._rightSource.getMetricFields() : []; } // Source request id must be static and unique because the re-fetch logic uses the id to locate the previous request. // Elasticsearch sources have a static and unique id so that requests can be modified in the inspector. // Using the right source id as the source request id because it meets the above criteria. getSourceDataRequestId() { - return `join_source_${this._rightSource.getId()}`; + return `join_source_${this._rightSource!.getId()}`; } getSourceMetaDataRequestId() { @@ -52,11 +66,17 @@ export class InnerJoin { return `${this.getSourceDataRequestId()}_${FORMATTERS_DATA_REQUEST_ID_SUFFIX}`; } - getLeftField() { + getLeftField(): IField { + if (!this._leftField) { + throw new Error('Cannot get leftField from InnerJoin with incomplete config'); + } return this._leftField; } - joinPropertiesToFeature(feature, propertiesMap) { + joinPropertiesToFeature(feature: Feature, propertiesMap: PropertiesMap): boolean { + if (!feature.properties || !this._leftField || !this._rightSource) { + return false; + } const rightMetricFields = this._rightSource.getMetricFields(); // delete feature properties added by previous join for (let j = 0; j < rightMetricFields.length; j++) { @@ -70,7 +90,7 @@ export class InnerJoin { featurePropertyKey.length >= stylePropertyPrefix.length && featurePropertyKey.substring(0, stylePropertyPrefix.length) === stylePropertyPrefix ) { - delete feature.properties[featurePropertyKey]; + delete feature.properties![featurePropertyKey]; } }); } @@ -78,7 +98,7 @@ export class InnerJoin { const joinKey = feature.properties[this._leftField.getName()]; const coercedKey = typeof joinKey === 'undefined' || joinKey === null ? null : joinKey.toString(); - if (propertiesMap && coercedKey !== null && propertiesMap.has(coercedKey)) { + if (coercedKey !== null && propertiesMap.has(coercedKey)) { Object.assign(feature.properties, propertiesMap.get(coercedKey)); return true; } else { @@ -86,27 +106,30 @@ export class InnerJoin { } } - getRightJoinSource() { + getRightJoinSource(): ESTermSource { + if (!this._rightSource) { + throw new Error('Cannot get rightSource from InnerJoin with incomplete config'); + } return this._rightSource; } - toDescriptor() { + toDescriptor(): JoinDescriptor { return this._descriptor; } - async getTooltipProperties(properties) { - return await this._rightSource.getTooltipProperties(properties); + async getTooltipProperties(properties: GeoJsonProperties) { + return await this.getRightJoinSource().getTooltipProperties(properties); } getIndexPatternIds() { - return this._rightSource.getIndexPatternIds(); + return this.getRightJoinSource().getIndexPatternIds(); } getQueryableIndexPatternIds() { - return this._rightSource.getQueryableIndexPatternIds(); + return this.getRightJoinSource().getQueryableIndexPatternIds(); } - getWhereQuery() { - return this._rightSource.getWhereQuery(); + getWhereQuery(): Query | undefined { + return this.getRightJoinSource().getWhereQuery(); } } diff --git a/x-pack/plugins/maps/public/classes/joins/join.ts b/x-pack/plugins/maps/public/classes/joins/join.ts deleted file mode 100644 index 465ffbda2730..000000000000 --- a/x-pack/plugins/maps/public/classes/joins/join.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Feature, GeoJsonProperties } from 'geojson'; -import { ESTermSource } from '../sources/es_term_source'; -import { JoinDescriptor } from '../../../common/descriptor_types'; -import { ITooltipProperty } from '../tooltips/tooltip_property'; -import { IField } from '../fields/field'; -import { PropertiesMap } from '../../../common/elasticsearch_util'; - -export interface IJoin { - destroy: () => void; - - getRightJoinSource: () => ESTermSource; - - toDescriptor: () => JoinDescriptor; - - getJoinFields: () => IField[]; - - getLeftField: () => IField; - - getIndexPatternIds: () => string[]; - - getQueryableIndexPatternIds: () => string[]; - - getSourceDataRequestId: () => string; - - getSourceMetaDataRequestId: () => string; - - getSourceFormattersDataRequestId: () => string; - - getTooltipProperties: (properties: GeoJsonProperties) => Promise; - - hasCompleteConfig: () => boolean; - - joinPropertiesToFeature: (feature: Feature, propertiesMap?: PropertiesMap) => boolean; -} diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index b4c0098bb133..e4ae0aed1572 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -53,7 +53,7 @@ import { } from '../../../../common/descriptor_types'; import { IVectorSource } from '../../sources/vector_source'; import { CustomIconAndTooltipContent, ILayer } from '../layer'; -import { IJoin } from '../../joins/join'; +import { InnerJoin } from '../../joins/inner_join'; import { IField } from '../../fields/field'; import { DataRequestContext } from '../../../actions'; import { ITooltipProperty } from '../../tooltips/tooltip_property'; @@ -68,21 +68,21 @@ interface SourceResult { interface JoinState { dataHasChanged: boolean; - join: IJoin; + join: InnerJoin; propertiesMap?: PropertiesMap; } export interface VectorLayerArguments { source: IVectorSource; - joins?: IJoin[]; + joins?: InnerJoin[]; layerDescriptor: VectorLayerDescriptor; } export interface IVectorLayer extends ILayer { getFields(): Promise; getStyleEditorFields(): Promise; - getJoins(): IJoin[]; - getValidJoins(): IJoin[]; + getJoins(): InnerJoin[]; + getValidJoins(): InnerJoin[]; getSource(): IVectorSource; getFeatureById(id: string | number): Feature | null; getPropertiesForTooltip(properties: GeoJsonProperties): Promise; @@ -93,7 +93,7 @@ export class VectorLayer extends AbstractLayer { static type = LAYER_TYPE.VECTOR; protected readonly _style: IVectorStyle; - private readonly _joins: IJoin[]; + private readonly _joins: InnerJoin[]; static createDescriptor( options: Partial, @@ -339,7 +339,7 @@ export class VectorLayer extends AbstractLayer { onLoadError, registerCancelCallback, dataFilters, - }: { join: IJoin } & DataRequestContext): Promise { + }: { join: InnerJoin } & DataRequestContext): Promise { const joinSource = join.getRightJoinSource(); const sourceDataId = join.getSourceDataRequestId(); const requestToken = Symbol(`layer-join-refresh:${this.getId()} - ${sourceDataId}`); @@ -453,10 +453,9 @@ export class VectorLayer extends AbstractLayer { for (let j = 0; j < joinStates.length; j++) { const joinState = joinStates[j]; const innerJoin = joinState.join; - const canJoinOnCurrent = innerJoin.joinPropertiesToFeature( - feature, - joinState.propertiesMap - ); + const canJoinOnCurrent = joinState.propertiesMap + ? innerJoin.joinPropertiesToFeature(feature, joinState.propertiesMap) + : false; isFeatureVisible = isFeatureVisible && canJoinOnCurrent; } @@ -559,7 +558,7 @@ export class VectorLayer extends AbstractLayer { }); } - async _syncJoinStyleMeta(syncContext: DataRequestContext, join: IJoin, style: IVectorStyle) { + async _syncJoinStyleMeta(syncContext: DataRequestContext, join: InnerJoin, style: IVectorStyle) { const joinSource = join.getRightJoinSource(); return this._syncStyleMeta({ source: joinSource, @@ -663,7 +662,7 @@ export class VectorLayer extends AbstractLayer { }); } - async _syncJoinFormatters(syncContext: DataRequestContext, join: IJoin, style: IVectorStyle) { + async _syncJoinFormatters(syncContext: DataRequestContext, join: InnerJoin, style: IVectorStyle) { const joinSource = join.getRightJoinSource(); return this._syncFormatters({ source: joinSource, diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts index 8ef50a1cb7a1..328594f00a1f 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts @@ -56,6 +56,9 @@ export class ESTermSource extends AbstractESAggSource { } return { ...normalizedDescriptor, + indexPatternTitle: descriptor.indexPatternTitle + ? descriptor.indexPatternTitle + : descriptor.indexPatternId, term: descriptor.term!, type: SOURCE_TYPES.ES_TERM_SOURCE, }; @@ -64,7 +67,7 @@ export class ESTermSource extends AbstractESAggSource { private readonly _termField: ESDocField; readonly _descriptor: ESTermSourceDescriptor; - constructor(descriptor: ESTermSourceDescriptor, inspectorAdapters: Adapters) { + constructor(descriptor: ESTermSourceDescriptor, inspectorAdapters?: Adapters) { const sourceDescriptor = ESTermSource.createDescriptor(descriptor); super(sourceDescriptor, inspectorAdapters); this._descriptor = sourceDescriptor; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx index 98b58def905e..c2cd46f26f99 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx @@ -29,7 +29,7 @@ import { } from '../../../../../common/descriptor_types'; import { IField } from '../../../fields/field'; import { IVectorLayer } from '../../../layers/vector_layer/vector_layer'; -import { IJoin } from '../../../joins/join'; +import { InnerJoin } from '../../../joins/inner_join'; import { IVectorStyle } from '../vector_style'; import { getComputedFieldName } from '../style_util'; @@ -88,7 +88,7 @@ export class DynamicStyleProperty return SOURCE_META_DATA_REQUEST_ID; } - const join = this._layer.getValidJoins().find((validJoin: IJoin) => { + const join = this._layer.getValidJoins().find((validJoin: InnerJoin) => { return validJoin.getRightJoinSource().hasMatchingMetricField(fieldName); }); return join ? join.getSourceMetaDataRequestId() : null; diff --git a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.ts b/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.ts index efdede82a744..5c45b33a7c31 100644 --- a/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.ts +++ b/x-pack/plugins/maps/public/classes/tooltips/join_tooltip_property.ts @@ -5,14 +5,14 @@ */ import { ITooltipProperty } from './tooltip_property'; -import { IJoin } from '../joins/join'; +import { InnerJoin } from '../joins/inner_join'; import { Filter } from '../../../../../../src/plugins/data/public'; export class JoinTooltipProperty implements ITooltipProperty { private readonly _tooltipProperty: ITooltipProperty; - private readonly _leftInnerJoins: IJoin[]; + private readonly _leftInnerJoins: InnerJoin[]; - constructor(tooltipProperty: ITooltipProperty, leftInnerJoins: IJoin[]) { + constructor(tooltipProperty: ITooltipProperty, leftInnerJoins: InnerJoin[]) { this._tooltipProperty = tooltipProperty; this._leftInnerJoins = leftInnerJoins; } diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index f6282be26b40..9a1b31852d39 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -20,7 +20,6 @@ import { getTimeFilter } from '../kibana_services'; import { getInspectorAdapters } from '../reducers/non_serializable_instances'; import { TiledVectorLayer } from '../classes/layers/tiled_vector_layer/tiled_vector_layer'; import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from '../reducers/util'; -import { IJoin } from '../classes/joins/join'; import { InnerJoin } from '../classes/joins/inner_join'; import { getSourceByType } from '../classes/sources/source_registry'; import { GeojsonFileSource } from '../classes/sources/geojson_file_source'; @@ -63,11 +62,11 @@ export function createLayerInstance( case TileLayer.type: return new TileLayer({ layerDescriptor, source: source as ITMSSource }); case VectorLayer.type: - const joins: IJoin[] = []; + const joins: InnerJoin[] = []; const vectorLayerDescriptor = layerDescriptor as VectorLayerDescriptor; if (vectorLayerDescriptor.joins) { vectorLayerDescriptor.joins.forEach((joinDescriptor) => { - const join = new InnerJoin(joinDescriptor, source); + const join = new InnerJoin(joinDescriptor, source as IVectorSource); joins.push(join); }); } @@ -357,7 +356,7 @@ export const getSelectedLayerJoinDescriptors = createSelector(getSelectedLayer, return []; } - return (selectedLayer as IVectorLayer).getJoins().map((join: IJoin) => { + return (selectedLayer as IVectorLayer).getJoins().map((join: InnerJoin) => { return join.toDescriptor(); }); }); From cac95a3bf63eac0d5e33c32c9e9ec5821d5bee57 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 24 Nov 2020 08:54:40 +0200 Subject: [PATCH 03/89] [Visualizations] Update the texts on the wizard (#82926) * [Visualizations] Update the texts on the wizard * Fix functional test * Final texts * Fix heatmap description Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../vis_type_metric/public/metric_vis_type.ts | 2 +- .../vis_type_table/public/table_vis_type.ts | 4 ++-- .../vis_type_tagcloud/public/tag_cloud_type.ts | 4 ++-- .../public/timelion_vis_type.tsx | 2 +- src/plugins/vis_type_vislib/public/area.ts | 2 +- src/plugins/vis_type_vislib/public/gauge.ts | 2 +- src/plugins/vis_type_vislib/public/goal.ts | 2 +- src/plugins/vis_type_vislib/public/heatmap.ts | 4 ++-- src/plugins/vis_type_vislib/public/histogram.ts | 4 ++-- .../vis_type_vislib/public/horizontal_bar.ts | 4 ++-- src/plugins/vis_type_vislib/public/line.ts | 2 +- src/plugins/vis_type_vislib/public/pie.ts | 2 +- test/functional/apps/visualize/_chart_types.ts | 10 +++++----- .../plugins/translations/translations/ja-JP.json | 16 ---------------- .../plugins/translations/translations/zh-CN.json | 16 ---------------- 15 files changed, 22 insertions(+), 54 deletions(-) diff --git a/src/plugins/vis_type_metric/public/metric_vis_type.ts b/src/plugins/vis_type_metric/public/metric_vis_type.ts index 1c5afd396c2c..f7c74e324053 100644 --- a/src/plugins/vis_type_metric/public/metric_vis_type.ts +++ b/src/plugins/vis_type_metric/public/metric_vis_type.ts @@ -30,7 +30,7 @@ export const createMetricVisTypeDefinition = (): BaseVisTypeOptions => ({ title: i18n.translate('visTypeMetric.metricTitle', { defaultMessage: 'Metric' }), icon: 'visMetric', description: i18n.translate('visTypeMetric.metricDescription', { - defaultMessage: 'Display a calculation as a single number', + defaultMessage: 'Show a calculation as a single number.', }), toExpressionAst, visConfig: { diff --git a/src/plugins/vis_type_table/public/table_vis_type.ts b/src/plugins/vis_type_table/public/table_vis_type.ts index bfc7abac0289..8546886e8350 100644 --- a/src/plugins/vis_type_table/public/table_vis_type.ts +++ b/src/plugins/vis_type_table/public/table_vis_type.ts @@ -29,11 +29,11 @@ import { TableVisParams } from './types'; export const tableVisTypeDefinition: BaseVisTypeOptions = { name: 'table', title: i18n.translate('visTypeTable.tableVisTitle', { - defaultMessage: 'Data Table', + defaultMessage: 'Data table', }), icon: 'visTable', description: i18n.translate('visTypeTable.tableVisDescription', { - defaultMessage: 'Display values in a table', + defaultMessage: 'Display data in rows and columns.', }), getSupportedTriggers: () => { return [VIS_EVENT_TO_TRIGGER.filter]; diff --git a/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts b/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts index d4c37649f949..71d4408ddc76 100644 --- a/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts +++ b/src/plugins/vis_type_tagcloud/public/tag_cloud_type.ts @@ -27,13 +27,13 @@ import { toExpressionAst } from './to_ast'; export const tagCloudVisTypeDefinition = { name: 'tagcloud', - title: i18n.translate('visTypeTagCloud.vis.tagCloudTitle', { defaultMessage: 'Tag Cloud' }), + title: i18n.translate('visTypeTagCloud.vis.tagCloudTitle', { defaultMessage: 'Tag cloud' }), icon: 'visTagCloud', getSupportedTriggers: () => { return [VIS_EVENT_TO_TRIGGER.filter]; }, description: i18n.translate('visTypeTagCloud.vis.tagCloudDescription', { - defaultMessage: 'A group of words, sized according to their importance', + defaultMessage: 'Display word frequency with font size.', }), visConfig: { defaults: { diff --git a/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx b/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx index a5425478e46a..5512fdccd5e7 100644 --- a/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx +++ b/src/plugins/vis_type_timelion/public/timelion_vis_type.tsx @@ -42,7 +42,7 @@ export function getTimelionVisDefinition(dependencies: TimelionVisDependencies) title: 'Timelion', icon: 'visTimelion', description: i18n.translate('timelion.timelionDescription', { - defaultMessage: 'Build time-series using functional expressions', + defaultMessage: 'Show time series data on a graph.', }), visConfig: { defaults: { diff --git a/src/plugins/vis_type_vislib/public/area.ts b/src/plugins/vis_type_vislib/public/area.ts index 531958d6b3db..ec7bce254f58 100644 --- a/src/plugins/vis_type_vislib/public/area.ts +++ b/src/plugins/vis_type_vislib/public/area.ts @@ -47,7 +47,7 @@ export const areaVisTypeDefinition: BaseVisTypeOptions = { title: i18n.translate('visTypeVislib.area.areaTitle', { defaultMessage: 'Area' }), icon: 'visArea', description: i18n.translate('visTypeVislib.area.areaDescription', { - defaultMessage: 'Emphasize the quantity beneath a line chart', + defaultMessage: 'Emphasize the data between an axis and a line.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush], toExpressionAst, diff --git a/src/plugins/vis_type_vislib/public/gauge.ts b/src/plugins/vis_type_vislib/public/gauge.ts index 2b3c415087ee..bd3bdd1a01e9 100644 --- a/src/plugins/vis_type_vislib/public/gauge.ts +++ b/src/plugins/vis_type_vislib/public/gauge.ts @@ -61,7 +61,7 @@ export const gaugeVisTypeDefinition: BaseVisTypeOptions = { title: i18n.translate('visTypeVislib.gauge.gaugeTitle', { defaultMessage: 'Gauge' }), icon: 'visGauge', description: i18n.translate('visTypeVislib.gauge.gaugeDescription', { - defaultMessage: 'Gauges indicate the status of a metric.', + defaultMessage: 'Show the status of a metric.', }), toExpressionAst, visConfig: { diff --git a/src/plugins/vis_type_vislib/public/goal.ts b/src/plugins/vis_type_vislib/public/goal.ts index 32574fb5b0a9..46878ca82e45 100644 --- a/src/plugins/vis_type_vislib/public/goal.ts +++ b/src/plugins/vis_type_vislib/public/goal.ts @@ -33,7 +33,7 @@ export const goalVisTypeDefinition: BaseVisTypeOptions = { title: i18n.translate('visTypeVislib.goal.goalTitle', { defaultMessage: 'Goal' }), icon: 'visGoal', description: i18n.translate('visTypeVislib.goal.goalDescription', { - defaultMessage: 'A goal chart indicates how close you are to your final goal.', + defaultMessage: 'Track how a metric progresses to a goal.', }), toExpressionAst, visConfig: { diff --git a/src/plugins/vis_type_vislib/public/heatmap.ts b/src/plugins/vis_type_vislib/public/heatmap.ts index f970eddd645f..c408ac140dd4 100644 --- a/src/plugins/vis_type_vislib/public/heatmap.ts +++ b/src/plugins/vis_type_vislib/public/heatmap.ts @@ -43,10 +43,10 @@ export interface HeatmapVisParams extends CommonVislibParams, ColorSchemaParams export const heatmapVisTypeDefinition: BaseVisTypeOptions = { name: 'heatmap', - title: i18n.translate('visTypeVislib.heatmap.heatmapTitle', { defaultMessage: 'Heat Map' }), + title: i18n.translate('visTypeVislib.heatmap.heatmapTitle', { defaultMessage: 'Heat map' }), icon: 'heatmap', description: i18n.translate('visTypeVislib.heatmap.heatmapDescription', { - defaultMessage: 'Shade cells within a matrix', + defaultMessage: 'Shade data in cells in a matrix.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], toExpressionAst, diff --git a/src/plugins/vis_type_vislib/public/histogram.ts b/src/plugins/vis_type_vislib/public/histogram.ts index d5fb92f5c6a0..de4855ba9aa2 100644 --- a/src/plugins/vis_type_vislib/public/histogram.ts +++ b/src/plugins/vis_type_vislib/public/histogram.ts @@ -44,11 +44,11 @@ import { toExpressionAst } from './to_ast'; export const histogramVisTypeDefinition: BaseVisTypeOptions = { name: 'histogram', title: i18n.translate('visTypeVislib.histogram.histogramTitle', { - defaultMessage: 'Vertical Bar', + defaultMessage: 'Vertical bar', }), icon: 'visBarVertical', description: i18n.translate('visTypeVislib.histogram.histogramDescription', { - defaultMessage: 'Assign a continuous variable to each axis', + defaultMessage: 'Present data in vertical bars on an axis.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush], toExpressionAst, diff --git a/src/plugins/vis_type_vislib/public/horizontal_bar.ts b/src/plugins/vis_type_vislib/public/horizontal_bar.ts index f1a5365e5ae7..144e63224533 100644 --- a/src/plugins/vis_type_vislib/public/horizontal_bar.ts +++ b/src/plugins/vis_type_vislib/public/horizontal_bar.ts @@ -42,11 +42,11 @@ import { toExpressionAst } from './to_ast'; export const horizontalBarVisTypeDefinition: BaseVisTypeOptions = { name: 'horizontal_bar', title: i18n.translate('visTypeVislib.horizontalBar.horizontalBarTitle', { - defaultMessage: 'Horizontal Bar', + defaultMessage: 'Horizontal bar', }), icon: 'visBarHorizontal', description: i18n.translate('visTypeVislib.horizontalBar.horizontalBarDescription', { - defaultMessage: 'Assign a continuous variable to each axis', + defaultMessage: 'Present data in horizontal bars on an axis.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush], toExpressionAst, diff --git a/src/plugins/vis_type_vislib/public/line.ts b/src/plugins/vis_type_vislib/public/line.ts index a65b0bcf7e2b..ffa40c8c2998 100644 --- a/src/plugins/vis_type_vislib/public/line.ts +++ b/src/plugins/vis_type_vislib/public/line.ts @@ -45,7 +45,7 @@ export const lineVisTypeDefinition: BaseVisTypeOptions = { title: i18n.translate('visTypeVislib.line.lineTitle', { defaultMessage: 'Line' }), icon: 'visLine', description: i18n.translate('visTypeVislib.line.lineDescription', { - defaultMessage: 'Emphasize trends', + defaultMessage: 'Display data as a series of points.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush], toExpressionAst, diff --git a/src/plugins/vis_type_vislib/public/pie.ts b/src/plugins/vis_type_vislib/public/pie.ts index 58f7dd0df89e..41b271054d59 100644 --- a/src/plugins/vis_type_vislib/public/pie.ts +++ b/src/plugins/vis_type_vislib/public/pie.ts @@ -43,7 +43,7 @@ export const pieVisTypeDefinition: BaseVisTypeOptions = { title: i18n.translate('visTypeVislib.pie.pieTitle', { defaultMessage: 'Pie' }), icon: 'visPie', description: i18n.translate('visTypeVislib.pie.pieDescription', { - defaultMessage: 'Compare parts of a whole', + defaultMessage: 'Compare data in proportion to a whole.', }), getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], toExpressionAst, diff --git a/test/functional/apps/visualize/_chart_types.ts b/test/functional/apps/visualize/_chart_types.ts index 4864fcbf3af0..d3949b36591a 100644 --- a/test/functional/apps/visualize/_chart_types.ts +++ b/test/functional/apps/visualize/_chart_types.ts @@ -49,18 +49,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { let expectedChartTypes = [ 'Area', 'Coordinate Map', - 'Data Table', + 'Data table', 'Gauge', 'Goal', - 'Heat Map', - 'Horizontal Bar', + 'Heat map', + 'Horizontal bar', 'Line', 'Metric', 'Pie', 'Region Map', - 'Tag Cloud', + 'Tag cloud', 'Timelion', - 'Vertical Bar', + 'Vertical bar', ]; if (!isOss) { expectedChartTypes = _.remove(expectedChartTypes, function (n) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ffb79f10e235..99e35d5caa62 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3453,7 +3453,6 @@ "timelion.serverSideErrors.wrongFunctionArgumentTypeErrorMessage": "{functionName} ({argumentName}) は {requiredTypes} の内の 1 つでなければなりません。{actualType} を入手", "timelion.serverSideErrors.yaxisFunction.notSupportedUnitTypeErrorMessage": "{units} はサポートされているユニットタイプではありません。.", "timelion.serverSideErrors.yaxisFunction.notValidCurrencyFormatErrorMessage": "通貨は 3 文字のコードでなければなりません", - "timelion.timelionDescription": "関数式で時系列チャートを作成します。", "timelion.topNavMenu.addChartButtonAriaLabel": "チャートを追加", "timelion.topNavMenu.addChartButtonLabel": "追加", "timelion.topNavMenu.delete.modal.confirmButtonLabel": "削除", @@ -3693,7 +3692,6 @@ "visTypeMetric.function.showLabels.help": "メトリック値の下にラベルを表示します。", "visTypeMetric.function.subText.help": "メトリックの下に表示するカスタムテキスト", "visTypeMetric.function.useRanges.help": "有効な色範囲です。", - "visTypeMetric.metricDescription": "計算結果を単独の数字として表示します。", "visTypeMetric.metricTitle": "メトリック", "visTypeMetric.params.color.useForLabel": "使用する色", "visTypeMetric.params.percentageModeLabel": "パーセンテージモード", @@ -3719,11 +3717,9 @@ "visTypeTable.params.showPartialRowsTip": "部分データのある行を表示。表示されていなくてもすべてのバケット/レベルのメトリックが計算されます。", "visTypeTable.params.showTotalLabel": "合計を表示", "visTypeTable.params.totalFunctionLabel": "合計機能", - "visTypeTable.tableVisDescription": "テーブルに値を表示します。", "visTypeTable.tableVisEditorConfig.schemas.bucketTitle": "行を分割", "visTypeTable.tableVisEditorConfig.schemas.metricTitle": "メトリック", "visTypeTable.tableVisEditorConfig.schemas.splitTitle": "テーブルを分割", - "visTypeTable.tableVisTitle": "データテーブル", "visTypeTable.totalAggregations.averageText": "平均", "visTypeTable.totalAggregations.countText": "カウント", "visTypeTable.totalAggregations.maxText": "最高", @@ -3745,8 +3741,6 @@ "visTypeTagCloud.vis.editorConfig.scales.squareRootText": "平方根", "visTypeTagCloud.vis.schemas.metricTitle": "タグサイズ", "visTypeTagCloud.vis.schemas.segmentTitle": "タグ", - "visTypeTagCloud.vis.tagCloudDescription": "重要度に基づき大きさを変えた単語のグループ表示です。", - "visTypeTagCloud.vis.tagCloudTitle": "タグクラウド", "visTypeTagCloud.visParams.fontSizeLabel": "フォントサイズ範囲 (ピクセル)", "visTypeTagCloud.visParams.orientationsLabel": "方向", "visTypeTagCloud.visParams.showLabelToggleLabel": "ラベルを表示", @@ -4344,7 +4338,6 @@ "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsText": "1つのデータソースが返せるバケットの最大数です。値が大きいとブラウザのレンダリング速度が下がる可能性があります。", "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsTitle": "ヒートマップの最大バケット数", "visTypeVislib.aggResponse.allDocsTitle": "すべてのドキュメント", - "visTypeVislib.area.areaDescription": "折れ線グラフの下の数量を強調します。", "visTypeVislib.area.areaTitle": "エリア", "visTypeVislib.area.countText": "カウント", "visTypeVislib.area.groupTitle": "系列を分割", @@ -4463,26 +4456,19 @@ "visTypeVislib.gauge.gaugeTypes.circleText": "円", "visTypeVislib.gauge.groupTitle": "グループを分割", "visTypeVislib.gauge.metricTitle": "メトリック", - "visTypeVislib.goal.goalDescription": "ゴールチャートは、最終目標にどれだけ近いかを示します。", "visTypeVislib.goal.goalTitle": "ゴール", "visTypeVislib.goal.groupTitle": "グループを分割", "visTypeVislib.goal.metricTitle": "メトリック", "visTypeVislib.heatmap.groupTitle": "Y 軸", - "visTypeVislib.heatmap.heatmapDescription": "マトリックス内のセルに影をつける。", - "visTypeVislib.heatmap.heatmapTitle": "ヒートマップ", "visTypeVislib.heatmap.metricTitle": "値", "visTypeVislib.heatmap.segmentTitle": "X 軸", "visTypeVislib.heatmap.splitTitle": "チャートを分割", "visTypeVislib.histogram.groupTitle": "系列を分割", - "visTypeVislib.histogram.histogramDescription": "連続変数を各軸に割り当てる。", - "visTypeVislib.histogram.histogramTitle": "縦棒", "visTypeVislib.histogram.metricTitle": "Y 軸", "visTypeVislib.histogram.radiusTitle": "点のサイズ", "visTypeVislib.histogram.segmentTitle": "X 軸", "visTypeVislib.histogram.splitTitle": "チャートを分割", "visTypeVislib.horizontalBar.groupTitle": "系列を分割", - "visTypeVislib.horizontalBar.horizontalBarDescription": "連続変数を各軸に割り当てる。", - "visTypeVislib.horizontalBar.horizontalBarTitle": "横棒", "visTypeVislib.horizontalBar.metricTitle": "Y 軸", "visTypeVislib.horizontalBar.radiusTitle": "点のサイズ", "visTypeVislib.horizontalBar.segmentTitle": "X 軸", @@ -4495,14 +4481,12 @@ "visTypeVislib.legendPositions.rightText": "右", "visTypeVislib.legendPositions.topText": "トップ", "visTypeVislib.line.groupTitle": "系列を分割", - "visTypeVislib.line.lineDescription": "トレンドを強調します。", "visTypeVislib.line.lineTitle": "折れ線", "visTypeVislib.line.metricTitle": "Y 軸", "visTypeVislib.line.radiusTitle": "点のサイズ", "visTypeVislib.line.segmentTitle": "X 軸", "visTypeVislib.line.splitTitle": "チャートを分割", "visTypeVislib.pie.metricTitle": "サイズのスライス", - "visTypeVislib.pie.pieDescription": "全体に対する内訳を表現する。", "visTypeVislib.pie.pieTitle": "パイ", "visTypeVislib.pie.segmentTitle": "スライスの分割", "visTypeVislib.pie.splitTitle": "チャートを分割", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 08461fba1083..d58d1063b9ae 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3454,7 +3454,6 @@ "timelion.serverSideErrors.wrongFunctionArgumentTypeErrorMessage": "{functionName}({argumentName}) 必须是 {requiredTypes} 之一。得到:{actualType}", "timelion.serverSideErrors.yaxisFunction.notSupportedUnitTypeErrorMessage": "{units} 为不受支持的单元类型。", "timelion.serverSideErrors.yaxisFunction.notValidCurrencyFormatErrorMessage": "货币必须使用三个字母的代码", - "timelion.timelionDescription": "使用函数表达式构建时间序列", "timelion.topNavMenu.addChartButtonAriaLabel": "添加图表", "timelion.topNavMenu.addChartButtonLabel": "添加", "timelion.topNavMenu.delete.modal.confirmButtonLabel": "删除", @@ -3694,7 +3693,6 @@ "visTypeMetric.function.showLabels.help": "在指标值下显示标签。", "visTypeMetric.function.subText.help": "要在指标下显示的定制文本", "visTypeMetric.function.useRanges.help": "已启用颜色范围。", - "visTypeMetric.metricDescription": "将计算结果显示为单个数字", "visTypeMetric.metricTitle": "指标", "visTypeMetric.params.color.useForLabel": "将颜色用于", "visTypeMetric.params.percentageModeLabel": "百分比模式", @@ -3720,11 +3718,9 @@ "visTypeTable.params.showPartialRowsTip": "显示具有部分数据的行。这仍将计算每个桶/级别的指标,即使它们未显示。", "visTypeTable.params.showTotalLabel": "显示汇总", "visTypeTable.params.totalFunctionLabel": "汇总函数", - "visTypeTable.tableVisDescription": "在表中显示值", "visTypeTable.tableVisEditorConfig.schemas.bucketTitle": "拆分行", "visTypeTable.tableVisEditorConfig.schemas.metricTitle": "指标", "visTypeTable.tableVisEditorConfig.schemas.splitTitle": "拆分表", - "visTypeTable.tableVisTitle": "数据表", "visTypeTable.totalAggregations.averageText": "平均值", "visTypeTable.totalAggregations.countText": "计数", "visTypeTable.totalAggregations.maxText": "最大值", @@ -3746,8 +3742,6 @@ "visTypeTagCloud.vis.editorConfig.scales.squareRootText": "平方根", "visTypeTagCloud.vis.schemas.metricTitle": "标记大小", "visTypeTagCloud.vis.schemas.segmentTitle": "标记", - "visTypeTagCloud.vis.tagCloudDescription": "一组字词,可根据其重要性调整大小", - "visTypeTagCloud.vis.tagCloudTitle": "标签云图", "visTypeTagCloud.visParams.fontSizeLabel": "字体大小范围(像素)", "visTypeTagCloud.visParams.orientationsLabel": "方向", "visTypeTagCloud.visParams.showLabelToggleLabel": "显示标签", @@ -4346,7 +4340,6 @@ "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsText": "单个数据源可以返回的最大存储桶数目。较高的数目可能对浏览器呈现性能有负面影响", "visTypeVislib.advancedSettings.visualization.heatmap.maxBucketsTitle": "热图最大存储桶数", "visTypeVislib.aggResponse.allDocsTitle": "所有文档", - "visTypeVislib.area.areaDescription": "突出折线图下方的数量", "visTypeVislib.area.areaTitle": "面积图", "visTypeVislib.area.countText": "计数", "visTypeVislib.area.groupTitle": "拆分序列", @@ -4465,26 +4458,19 @@ "visTypeVislib.gauge.gaugeTypes.circleText": "圆形", "visTypeVislib.gauge.groupTitle": "拆分组", "visTypeVislib.gauge.metricTitle": "指标", - "visTypeVislib.goal.goalDescription": "目标图指示与最终目标的接近程度。", "visTypeVislib.goal.goalTitle": "目标图", "visTypeVislib.goal.groupTitle": "拆分组", "visTypeVislib.goal.metricTitle": "指标", "visTypeVislib.heatmap.groupTitle": "Y 轴", - "visTypeVislib.heatmap.heatmapDescription": "为矩阵中的单元格添加阴影", - "visTypeVislib.heatmap.heatmapTitle": "热力图", "visTypeVislib.heatmap.metricTitle": "值", "visTypeVislib.heatmap.segmentTitle": "X 轴", "visTypeVislib.heatmap.splitTitle": "拆分图表", "visTypeVislib.histogram.groupTitle": "拆分序列", - "visTypeVislib.histogram.histogramDescription": "向每个轴赋予连续变量", - "visTypeVislib.histogram.histogramTitle": "垂直条形图", "visTypeVislib.histogram.metricTitle": "Y 轴", "visTypeVislib.histogram.radiusTitle": "点大小", "visTypeVislib.histogram.segmentTitle": "X 轴", "visTypeVislib.histogram.splitTitle": "拆分图表", "visTypeVislib.horizontalBar.groupTitle": "拆分序列", - "visTypeVislib.horizontalBar.horizontalBarDescription": "向每个轴赋予连续变量", - "visTypeVislib.horizontalBar.horizontalBarTitle": "水平条形图", "visTypeVislib.horizontalBar.metricTitle": "Y 轴", "visTypeVislib.horizontalBar.radiusTitle": "点大小", "visTypeVislib.horizontalBar.segmentTitle": "X 轴", @@ -4497,14 +4483,12 @@ "visTypeVislib.legendPositions.rightText": "右", "visTypeVislib.legendPositions.topText": "上", "visTypeVislib.line.groupTitle": "拆分序列", - "visTypeVislib.line.lineDescription": "突出趋势", "visTypeVislib.line.lineTitle": "折线图", "visTypeVislib.line.metricTitle": "Y 轴", "visTypeVislib.line.radiusTitle": "点大小", "visTypeVislib.line.segmentTitle": "X 轴", "visTypeVislib.line.splitTitle": "拆分图表", "visTypeVislib.pie.metricTitle": "切片大小", - "visTypeVislib.pie.pieDescription": "比较整体的各个部分", "visTypeVislib.pie.pieTitle": "饼图", "visTypeVislib.pie.segmentTitle": "拆分切片", "visTypeVislib.pie.splitTitle": "拆分图表", From 91ff8e45ab3d0e2b64a7dd588f64c3052e29154b Mon Sep 17 00:00:00 2001 From: Daniil Date: Tue, 24 Nov 2020 10:48:32 +0300 Subject: [PATCH 04/89] Explicitly pass params (#84107) --- src/plugins/vis_type_tagcloud/public/to_ast.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_type_tagcloud/public/to_ast.ts b/src/plugins/vis_type_tagcloud/public/to_ast.ts index 876784cc1014..69b55b589825 100644 --- a/src/plugins/vis_type_tagcloud/public/to_ast.ts +++ b/src/plugins/vis_type_tagcloud/public/to_ast.ts @@ -44,9 +44,14 @@ export const toExpressionAst = (vis: Vis, params: BuildPipeli }); const schemas = getVisSchemas(vis, params); + const { scale, orientation, minFontSize, maxFontSize, showLabel } = vis.params; const tagcloud = buildExpressionFunction('tagcloud', { - ...vis.params, + scale, + orientation, + minFontSize, + maxFontSize, + showLabel, metric: prepareDimension(schemas.metric[0]), }); From b11f7830cb22316fd4d0beb9d402ccd2b8ca1317 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Tue, 24 Nov 2020 00:07:47 -0800 Subject: [PATCH 05/89] [Alerting UI] Replaced AppContextProvider introduced by the plugin with KibanaContextProvider (#83248) * Replaced AppContextProvider introduced by the plugin with KibanaContextProvider * Removed unused files * Fixed jest test * Removed ActionsConnectorContext * exposed addConnectorFlyout and editConnectorFlyouts as a plugin start result * removed rest of unused connectors context * fixed capabilities * fixed jest tests * fixed jest tests * fixed jest tests * fixed uptime * fixed typecheck errors * fixed typechecks * fixed jest tests * fixed type * fixed uptime settings by pathing the correct plugin dependancy * fixed security detection rules * fixed due to commetns * fixed jest tests * fixed type check * removed orig files * fixed cases UI issues * fixed due to comments * fixed due to comments * fixed kibana crash * fixed es-lint --- .../alerting_example/public/plugin.tsx | 7 +- x-pack/plugins/infra/public/types.ts | 7 +- .../components/configure_cases/index.test.tsx | 48 ++- .../components/configure_cases/index.tsx | 61 ++-- .../rules/rule_actions_field/index.tsx | 7 - x-pack/plugins/triggers_actions_ui/README.md | 38 +-- .../plugins/triggers_actions_ui/kibana.json | 2 +- .../public/application/app.tsx | 54 ++-- .../public/application/app_context.tsx | 35 --- .../public/application/boot.tsx | 33 -- .../email/email_connector.test.tsx | 6 +- .../email/email_connector.tsx | 4 +- .../email/email_params.test.tsx | 6 - .../es_index/es_index_connector.test.tsx | 27 +- .../es_index/es_index_connector.tsx | 4 +- .../es_index/es_index_params.test.tsx | 7 +- .../es_index/es_index_params.tsx | 3 +- .../jira/jira_connectors.test.tsx | 18 +- .../jira/jira_connectors.tsx | 1 - .../jira/jira_params.test.tsx | 27 +- .../builtin_action_types/jira/jira_params.tsx | 13 +- .../pagerduty/pagerduty_connectors.test.tsx | 14 +- .../pagerduty/pagerduty_connectors.tsx | 4 +- .../pagerduty/pagerduty_params.test.tsx | 6 - .../resilient/resilient_connectors.test.tsx | 18 +- .../resilient/resilient_connectors.tsx | 1 - .../resilient/resilient_params.test.tsx | 24 +- .../resilient/resilient_params.tsx | 11 +- .../server_log/server_log_params.test.tsx | 10 - .../servicenow/servicenow_connectors.test.tsx | 18 +- .../servicenow/servicenow_connectors.tsx | 5 +- .../servicenow/servicenow_params.test.tsx | 10 - .../slack/slack_connectors.test.tsx | 14 +- .../slack/slack_connectors.tsx | 4 +- .../slack/slack_params.test.tsx | 6 - .../teams/teams_connectors.test.tsx | 14 +- .../teams/teams_connectors.tsx | 4 +- .../teams/teams_params.test.tsx | 7 +- .../webhook/webhook_connectors.test.tsx | 4 - .../webhook/webhook_params.test.tsx | 6 - .../components/delete_modal_confirmation.tsx | 11 +- .../context/actions_connectors_context.tsx | 41 --- .../public/application/home.test.tsx | 15 +- .../public/application/home.tsx | 10 +- .../action_connector_form.test.tsx | 42 +-- .../action_connector_form.tsx | 14 +- .../action_form.test.tsx | 42 +-- .../action_connector_form/action_form.tsx | 37 +-- .../action_type_form.tsx | 20 +- .../action_type_menu.test.tsx | 138 +++------ .../action_type_menu.tsx | 15 +- .../connector_add_flyout.test.tsx | 228 ++++++-------- .../connector_add_flyout.tsx | 40 +-- .../connector_add_inline.tsx | 7 +- .../connector_add_modal.test.tsx | 34 +- .../connector_add_modal.tsx | 45 ++- .../connector_edit_flyout.test.tsx | 77 ++--- .../connector_edit_flyout.tsx | 31 +- .../test_connector_form.test.tsx | 156 +++------- .../test_connector_form.tsx | 9 +- .../actions_connectors_list.test.tsx | 292 +++++------------- .../components/actions_connectors_list.tsx | 69 ++--- .../components/alert_details.test.tsx | 55 ++-- .../components/alert_details.tsx | 10 +- .../components/alert_details_route.test.tsx | 7 +- .../components/alert_details_route.tsx | 15 +- .../components/alert_instances.test.tsx | 8 +- .../components/alert_instances_route.test.tsx | 7 +- .../components/alert_instances_route.tsx | 17 +- .../components/view_in_app.test.tsx | 31 +- .../alert_details/components/view_in_app.tsx | 7 +- .../sections/alert_form/alert_add.test.tsx | 7 +- .../sections/alert_form/alert_edit.test.tsx | 6 +- .../sections/alert_form/alert_form.tsx | 4 - .../components/alerts_list.test.tsx | 176 ++--------- .../alerts_list/components/alerts_list.tsx | 18 +- .../components/alert_quick_edit_buttons.tsx | 16 +- .../with_actions_api_operations.test.tsx | 15 +- .../with_actions_api_operations.tsx | 7 +- .../with_bulk_alert_api_operations.test.tsx | 42 +-- .../with_bulk_alert_api_operations.tsx | 7 +- .../public/application/test_utils/index.ts | 45 --- .../common/get_add_connector_flyout.tsx | 18 ++ .../common/get_edit_connector_flyout.tsx | 18 ++ .../common/lib/kibana/__mocks__/index.ts | 32 ++ .../public/common/lib/kibana/index.ts | 7 + .../common/lib/kibana/kibana_react.mock.ts | 64 ++++ .../public/common/lib/kibana/kibana_react.ts | 30 ++ .../triggers_actions_ui/public/index.ts | 2 +- .../triggers_actions_ui/public/plugin.ts | 34 +- .../triggers_actions_ui/public/types.ts | 7 +- .../plugins/uptime/public/apps/uptime_app.tsx | 4 +- .../settings/add_connector_flyout.tsx | 47 +-- 93 files changed, 1001 insertions(+), 1723 deletions(-) delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/app_context.tsx delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/boot.tsx delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/test_utils/index.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/get_add_connector_flyout.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/get_edit_connector_flyout.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/__mocks__/index.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/index.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.ts diff --git a/x-pack/examples/alerting_example/public/plugin.tsx b/x-pack/examples/alerting_example/public/plugin.tsx index eebb1e2687ac..5e552bd1b180 100644 --- a/x-pack/examples/alerting_example/public/plugin.tsx +++ b/x-pack/examples/alerting_example/public/plugin.tsx @@ -12,7 +12,10 @@ import { } from '../../../../src/core/public'; import { PluginSetupContract as AlertingSetup } from '../../../plugins/alerts/public'; import { ChartsPluginStart } from '../../../../src/plugins/charts/public'; -import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, +} from '../../../plugins/triggers_actions_ui/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { getAlertType as getAlwaysFiringAlertType } from './alert_types/always_firing'; import { getAlertType as getPeopleInSpaceAlertType } from './alert_types/astros'; @@ -30,7 +33,7 @@ export interface AlertingExamplePublicSetupDeps { export interface AlertingExamplePublicStartDeps { alerts: AlertingSetup; - triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; charts: ChartsPluginStart; data: DataPublicPluginStart; } diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index 116345b35fdc..f1052672978d 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -11,7 +11,10 @@ import type { UsageCollectionSetup, UsageCollectionStart, } from '../../../../src/plugins/usage_collection/public'; -import type { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; +import type { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, +} from '../../../plugins/triggers_actions_ui/public'; import type { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; import type { ObservabilityPluginSetup, @@ -37,7 +40,7 @@ export interface InfraClientStartDeps { dataEnhanced: DataEnhancedStart; observability: ObservabilityPluginStart; spaces: SpacesPluginStart; - triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; usageCollection: UsageCollectionStart; ml: MlPluginStart; } diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index e7a4e7091180..5150a907ae71 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -12,7 +12,7 @@ import { TestProviders } from '../../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; import { - ActionsConnectorsContextProvider, + ActionConnector, ConnectorAddFlyout, ConnectorEditFlyout, TriggersAndActionsUIPublicPluginStart, @@ -41,6 +41,47 @@ describe('ConfigureCases', () => { beforeEach(() => { useKibanaMock().services.triggersActionsUi = ({ actionTypeRegistry: actionTypeRegistryMock.create(), + getAddConnectorFlyout: jest.fn().mockImplementation(() => ( + {}} + actionTypeRegistry={actionTypeRegistryMock.create()} + actionTypes={[ + { + id: '.servicenow', + name: 'servicenow', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + }, + { + id: '.jira', + name: 'jira', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + }, + { + id: '.resilient', + name: 'resilient', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + }, + ]} + /> + )), + getEditConnectorFlyout: jest + .fn() + .mockImplementation(() => ( + {}} + actionTypeRegistry={actionTypeRegistryMock.create()} + initialConnector={connectors[1] as ActionConnector} + /> + )), } as unknown) as TriggersAndActionsUIPublicPluginStart; }); @@ -62,11 +103,6 @@ describe('ConfigureCases', () => { expect(wrapper.find('[data-test-subj="closure-options-radio-group"]').exists()).toBeTruthy(); }); - test('it renders the ActionsConnectorsContextProvider', () => { - // Components from triggersActionsUi do not have a data-test-subj - expect(wrapper.find(ActionsConnectorsContextProvider).exists()).toBeTruthy(); - }); - test('it does NOT render the ConnectorAddFlyout', () => { // Components from triggersActionsUi do not have a data-test-subj expect(wrapper.find(ConnectorAddFlyout).exists()).toBeFalsy(); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx index 4bf774dde237..1c24738f2b2c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled, { css } from 'styled-components'; import { EuiCallOut } from '@elastic/eui'; @@ -12,12 +12,7 @@ import { EuiCallOut } from '@elastic/eui'; import { useKibana } from '../../../common/lib/kibana'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; -import { - ActionsConnectorsContextProvider, - ActionType, - ConnectorAddFlyout, - ConnectorEditFlyout, -} from '../../../../../triggers_actions_ui/public'; +import { ActionType } from '../../../../../triggers_actions_ui/public'; import { ClosureType } from '../../containers/configure/types'; @@ -61,7 +56,7 @@ interface ConfigureCasesComponentProps { } const ConfigureCasesComponent: React.FC = ({ userCanCrud }) => { - const { http, triggersActionsUi, notifications, application, docLinks } = useKibana().services; + const { triggersActionsUi } = useKibana().services; const [connectorIsValid, setConnectorIsValid] = useState(true); const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); @@ -155,6 +150,32 @@ const ConfigureCasesComponent: React.FC = ({ userC } }, [connectors, connector, isLoadingConnectors]); + const ConnectorAddFlyout = useMemo( + () => + triggersActionsUi.getAddConnectorFlyout({ + consumer: 'case', + onClose: onCloseAddFlyout, + actionTypes, + reloadConnectors, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const ConnectorEditFlyout = useMemo( + () => + editedConnectorItem && editFlyoutVisible + ? triggersActionsUi.getEditConnectorFlyout({ + initialConnector: editedConnectorItem, + consumer: 'case', + onClose: onCloseEditFlyout, + reloadConnectors, + }) + : null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [connector.id, editFlyoutVisible] + ); + return ( {!connectorIsValid && ( @@ -187,28 +208,8 @@ const ConfigureCasesComponent: React.FC = ({ userC selectedConnector={connector.id} /> - - {addFlyoutVisible && ( - - )} - {editedConnectorItem && editFlyoutVisible && ( - - )} - + {addFlyoutVisible && ConnectorAddFlyout} + {ConnectorEditFlyout} ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx index 0211788509db..b653fc05850a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx @@ -46,9 +46,6 @@ export const RuleActionsField: React.FC = ({ field, messageVariables }) = const { http, triggersActionsUi: { actionTypeRegistry }, - notifications, - docLinks, - application: { capabilities }, } = useKibana().services; const actions: AlertAction[] = useMemo( @@ -119,18 +116,14 @@ export const RuleActionsField: React.FC = ({ field, messageVariables }) = ) : null} ); diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index 3e5e95996c80..28667741801f 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -1588,15 +1588,6 @@ const connector = { // in render section of component - - ``` ConnectorAddFlyout Props definition: @@ -1695,29 +1687,23 @@ const { http, triggersActionsUi, notifications, application } = useKibana().serv // in render section of component - - + initialConnector={editedConnectorItem} + onClose={onCloseEditFlyout} + reloadConnectors={reloadConnectors} + consumer={'alerts'} + /> ``` ConnectorEditFlyout Props definition: ``` export interface ConnectorEditProps { - initialConnector: ActionConnectorTableItem; - editFlyoutVisible: boolean; - setEditFlyoutVisibility: React.Dispatch>; + initialConnector: ActionConnector; + onClose: () => void; + tab?: EditConectorTabs; + reloadConnectors?: () => Promise; + consumer?: string; } ``` diff --git a/x-pack/plugins/triggers_actions_ui/kibana.json b/x-pack/plugins/triggers_actions_ui/kibana.json index ab2d6c6a3c40..0487c58e6626 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.json +++ b/x-pack/plugins/triggers_actions_ui/kibana.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "optionalPlugins": ["alerts", "features", "home"], - "requiredPlugins": ["management", "charts", "data", "kibanaReact", "savedObjects"], + "requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects"], "configPath": ["xpack", "trigger_actions_ui"], "extraPublicDirs": ["public/common", "public/common/constants"], "requiredBundles": ["home", "alerts", "esUiShared", "kibanaReact", "kibanaUtils"] diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index fa38c4501379..93614dd191e0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -5,21 +5,11 @@ */ import React, { lazy } from 'react'; import { Switch, Route, Redirect, Router } from 'react-router-dom'; -import { - ChromeStart, - DocLinksStart, - ToastsSetup, - HttpSetup, - IUiSettingsClient, - ApplicationStart, - ChromeBreadcrumb, - CoreStart, - ScopedHistory, - SavedObjectsClientContract, -} from 'kibana/public'; +import { ChromeBreadcrumb, CoreStart, ScopedHistory } from 'kibana/public'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; import { KibanaFeature } from '../../../features/common'; import { Section, routeToAlertDetails } from './constants'; -import { AppContextProvider } from './app_context'; import { ActionTypeRegistryContract, AlertTypeRegistryContract } from '../types'; import { ChartsPluginStart } from '../../../../../src/plugins/charts/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; @@ -27,45 +17,47 @@ import { PluginStartContract as AlertingStart } from '../../../alerts/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { setSavedObjectsClient } from '../common/lib/data_apis'; +import { KibanaContextProvider } from '../common/lib/kibana'; + const TriggersActionsUIHome = lazy(async () => import('./home')); const AlertDetailsRoute = lazy( () => import('./sections/alert_details/components/alert_details_route') ); -export interface AppDeps { +export interface TriggersAndActionsUiServices extends CoreStart { data: DataPublicPluginStart; charts: ChartsPluginStart; - chrome: ChromeStart; alerts?: AlertingStart; - navigateToApp: CoreStart['application']['navigateToApp']; - docLinks: DocLinksStart; - toastNotifications: ToastsSetup; storage?: Storage; - http: HttpSetup; - uiSettings: IUiSettingsClient; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; - capabilities: ApplicationStart['capabilities']; actionTypeRegistry: ActionTypeRegistryContract; alertTypeRegistry: AlertTypeRegistryContract; history: ScopedHistory; - savedObjects?: { - client: SavedObjectsClientContract; - }; kibanaFeatures: KibanaFeature[]; + element: HTMLElement; } -export const App = (appDeps: AppDeps) => { +export const renderApp = (deps: TriggersAndActionsUiServices) => { + const { element, savedObjects } = deps; const sections: Section[] = ['alerts', 'connectors']; const sectionsRegex = sections.join('|'); + setSavedObjectsClient(savedObjects.client); - return ( - - - - - + render( + + + + + + + , + element ); + return () => { + unmountComponentAtNode(element); + }; }; export const AppWithoutRouter = ({ sectionsRegex }: { sectionsRegex: string }) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app_context.tsx deleted file mode 100644 index a4568d069c21..000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/app_context.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { createContext, useContext } from 'react'; -import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; -import { AppDeps } from './app'; - -const AppContext = createContext(null); - -export const AppContextProvider = ({ - children, - appDeps, -}: { - appDeps: AppDeps | null; - children: React.ReactNode; -}) => { - return appDeps ? ( - - {children} - - ) : null; -}; - -export const useAppDependencies = (): AppDeps => { - const ctx = useContext(AppContext); - if (!ctx) { - throw new Error( - 'The app dependencies Context has not been set. Use the "setAppDependencies()" method when bootstrapping the app.' - ); - } - return ctx; -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/boot.tsx b/x-pack/plugins/triggers_actions_ui/public/application/boot.tsx deleted file mode 100644 index e18bf4ce8487..000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/boot.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { App, AppDeps } from './app'; -import { setSavedObjectsClient } from '../common/lib/data_apis'; - -interface BootDeps extends AppDeps { - element: HTMLElement; - I18nContext: any; -} - -export const boot = (bootDeps: BootDeps) => { - const { I18nContext, element, ...appDeps } = bootDeps; - - if (appDeps.savedObjects) { - setSavedObjectsClient(appDeps.savedObjects.client); - } - - render( - - - , - element - ); - return () => { - unmountComponentAtNode(element); - }; -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx index 4fe76b9ff3c2..60ee7e4ad2bf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx @@ -7,8 +7,8 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { EmailActionConnector } from '../types'; import EmailActionConnectorFields from './email_connector'; -import { DocLinksStart } from 'kibana/public'; +jest.mock('../../../../common/lib/kibana'); describe('EmailActionConnectorFields renders', () => { test('all connector fields is rendered', () => { const actionConnector = { @@ -30,7 +30,6 @@ describe('EmailActionConnectorFields renders', () => { errors={{ from: [], port: [], host: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); @@ -61,7 +60,6 @@ describe('EmailActionConnectorFields renders', () => { errors={{ from: [], port: [], host: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); @@ -89,7 +87,6 @@ describe('EmailActionConnectorFields renders', () => { errors={{ from: [], port: [], host: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); @@ -114,7 +111,6 @@ describe('EmailActionConnectorFields renders', () => { errors={{ from: [], port: [], host: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx index a5f1f2571206..696941e23d4b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx @@ -22,10 +22,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiLink } from '@elastic/eui'; import { ActionConnectorFieldsProps } from '../../../../types'; import { EmailActionConnector } from '../types'; +import { useKibana } from '../../../../common/lib/kibana'; export const EmailActionConnectorFields: React.FunctionComponent< ActionConnectorFieldsProps -> = ({ action, editActionConfig, editActionSecrets, errors, readOnly, docLinks }) => { +> = ({ action, editActionConfig, editActionSecrets, errors, readOnly }) => { + const { docLinks } = useKibana().services; const { from, host, port, secure, hasAuth } = action.config; const { user, password } = action.secrets; useEffect(() => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx index 1198fc26df80..3cd54b58bf29 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx @@ -5,13 +5,10 @@ */ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; import EmailParamsFields from './email_params'; describe('EmailParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { cc: [], bcc: [], @@ -26,9 +23,6 @@ describe('EmailParamsFields renders', () => { errors={{ to: [], cc: [], bcc: [], subject: [], message: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx index 721cb18e1f36..4000c92c371c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -7,10 +7,8 @@ import React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { act } from 'react-dom/test-utils'; import { EsIndexActionConnector } from '../types'; -import { coreMock } from '../../../../../../../../src/core/public/mocks'; import IndexActionConnectorFields from './es_index_connector'; -import { TypeRegistry } from '../../../type_registry'; -import { DocLinksStart } from 'kibana/public'; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/index_controls', () => ({ firstFieldOption: jest.fn(), @@ -21,27 +19,6 @@ jest.mock('../../../../common/index_controls', () => ({ describe('IndexActionConnectorFields renders', () => { test('all connector fields is rendered', async () => { - const mocks = coreMock.createSetup(); - const [ - { - application: { capabilities }, - }, - ] = await mocks.getStartServices(); - const deps = { - toastNotifications: mocks.notifications.toasts, - http: mocks.http, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, - }, - actionTypeRegistry: {} as TypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; - const { getIndexPatterns } = jest.requireMock('../../../../common/index_controls'); getIndexPatterns.mockResolvedValueOnce([ { @@ -86,8 +63,6 @@ describe('IndexActionConnectorFields renders', () => { errors={{ index: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - http={deps!.http} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx index ba2f65659cd0..9299bf573b52 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx @@ -26,10 +26,12 @@ import { getIndexOptions, getIndexPatterns, } from '../../../../common/index_controls'; +import { useKibana } from '../../../../common/lib/kibana'; const IndexActionConnectorFields: React.FunctionComponent< ActionConnectorFieldsProps -> = ({ action, editActionConfig, errors, http, readOnly, docLinks }) => { +> = ({ action, editActionConfig, errors, readOnly }) => { + const { http, docLinks } = useKibana().services; const { index, refresh, executionTimeField } = action.config; const [hasTimeFieldCheckbox, setTimeFieldCheckboxState] = useState( executionTimeField != null diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx index 00ec68730442..e97d1ef2c2e7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx @@ -6,12 +6,10 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import ParamsFields from './es_index_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; +jest.mock('../../../../common/lib/kibana'); describe('IndexParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { documents: [{ test: 123 }], }; @@ -22,9 +20,6 @@ describe('IndexParamsFields renders', () => { errors={{ index: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="documentsJsonEditor"]').first().prop('value')).toBe(`{ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index 0a04db1b5ddf..fbbd36aa077c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -10,15 +10,16 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ActionParamsProps } from '../../../../types'; import { IndexActionParams } from '.././types'; import { JsonEditorWithMessageVariables } from '../../json_editor_with_message_variables'; +import { useKibana } from '../../../../common/lib/kibana'; export const IndexParamsFields = ({ actionParams, index, editAction, messageVariables, - docLinks, errors, }: ActionParamsProps) => { + const { docLinks } = useKibana().services; const { documents } = actionParams; const onDocumentsChange = (updatedDocuments: string) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx index 1bcb528bbd6c..e3f9bb99b48b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.test.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { DocLinksStart } from 'kibana/public'; import JiraConnectorFields from './jira_connectors'; import { JiraActionConnector } from './types'; +jest.mock('../../../../common/lib/kibana'); describe('JiraActionConnectorFields renders', () => { test('alerting Jira connector fields is rendered', () => { @@ -25,16 +25,12 @@ describe('JiraActionConnectorFields renders', () => { projectKey: 'CK', }, } as JiraActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -68,16 +64,12 @@ describe('JiraActionConnectorFields renders', () => { projectKey: 'CK', }, } as JiraActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} consumer={'case'} /> @@ -104,16 +96,12 @@ describe('JiraActionConnectorFields renders', () => { secrets: {}, config: {}, } as JiraActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -136,16 +124,12 @@ describe('JiraActionConnectorFields renders', () => { projectKey: 'CK', }, } as JiraActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx index 64ae752afa90..f32b521797f5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx @@ -32,7 +32,6 @@ const JiraConnectorFields: React.FC { // TODO: remove incidentConfiguration later, when Case Jira will move their fields to the level of action execution const { apiUrl, projectKey, incidentConfiguration, isCaseOwned } = action.config; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx index 671a575695d6..89a7c44c60db 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx @@ -6,18 +6,14 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import JiraParamsFields from './jira_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; - import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { ActionConnector } from '../../../../types'; +jest.mock('../../../../common/lib/kibana'); jest.mock('./use_get_issue_types'); jest.mock('./use_get_fields_by_issue_type'); -const mocks = coreMock.createSetup(); - const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; @@ -91,9 +87,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[{ name: 'alertId', description: '' }]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -120,9 +113,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -139,9 +129,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -164,9 +151,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -189,9 +173,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -218,9 +199,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -247,9 +225,6 @@ describe('JiraParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index 880e39aada44..385872ed67bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -29,6 +29,7 @@ import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { SearchIssues } from './search_issues'; import { extractActionVariable } from '../extract_action_variable'; +import { useKibana } from '../../../../common/lib/kibana'; const JiraParamsFields: React.FunctionComponent> = ({ actionParams, @@ -37,9 +38,11 @@ const JiraParamsFields: React.FunctionComponent { + const { + http, + notifications: { toasts }, + } = useKibana().services; const { title, description, comments, issueType, priority, labels, parent, savedObjectId } = actionParams.subActionParams || {}; @@ -57,13 +60,13 @@ const JiraParamsFields: React.FunctionComponent { editSubActionProperty('parent', parentIssueKey); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx index afd7c429805a..8481a8931e7b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx @@ -8,7 +8,7 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { act } from 'react-dom/test-utils'; import { PagerDutyActionConnector } from '.././types'; import PagerDutyActionConnectorFields from './pagerduty_connectors'; -import { DocLinksStart } from 'kibana/public'; +jest.mock('../../../../common/lib/kibana'); describe('PagerDutyActionConnectorFields renders', () => { test('all connector fields is rendered', async () => { @@ -23,9 +23,6 @@ describe('PagerDutyActionConnectorFields renders', () => { apiUrl: 'http:\\test', }, } as PagerDutyActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( { errors={{ index: [], routingKey: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -56,16 +52,12 @@ describe('PagerDutyActionConnectorFields renders', () => { secrets: {}, config: {}, } as PagerDutyActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -85,16 +77,12 @@ describe('PagerDutyActionConnectorFields renders', () => { apiUrl: 'http:\\test', }, } as PagerDutyActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx index cc2e004d5a1d..11181196289a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx @@ -9,10 +9,12 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; import { PagerDutyActionConnector } from '.././types'; +import { useKibana } from '../../../../common/lib/kibana'; const PagerDutyActionConnectorFields: React.FunctionComponent< ActionConnectorFieldsProps -> = ({ errors, action, editActionConfig, editActionSecrets, docLinks, readOnly }) => { +> = ({ errors, action, editActionConfig, editActionSecrets, readOnly }) => { + const { docLinks } = useKibana().services; const { apiUrl } = action.config; const { routingKey } = action.secrets; return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx index 6236d7a751e0..8b466f1a50a0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx @@ -7,12 +7,9 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { EventActionOptions, SeverityActionOptions } from '.././types'; import PagerDutyParamsFields from './pagerduty_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; describe('PagerDutyParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { eventAction: EventActionOptions.TRIGGER, dedupKey: 'test', @@ -31,9 +28,6 @@ describe('PagerDutyParamsFields renders', () => { errors={{ summary: [], timestamp: [], dedupKey: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx index 23eebcb4ac01..a285c9621903 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.test.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { DocLinksStart } from 'kibana/public'; import ResilientConnectorFields from './resilient_connectors'; import { ResilientActionConnector } from './types'; +jest.mock('../../../../common/lib/kibana'); describe('ResilientActionConnectorFields renders', () => { test('alerting Resilient connector fields is rendered', () => { @@ -25,16 +25,12 @@ describe('ResilientActionConnectorFields renders', () => { orgId: '201', }, } as ResilientActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -68,16 +64,12 @@ describe('ResilientActionConnectorFields renders', () => { orgId: '201', }, } as ResilientActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} consumer={'case'} /> @@ -105,16 +97,12 @@ describe('ResilientActionConnectorFields renders', () => { config: {}, secrets: {}, } as ResilientActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -137,16 +125,12 @@ describe('ResilientActionConnectorFields renders', () => { orgId: '201', }, } as ResilientActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx index 68626e8a0d3f..cf7596442a02 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx @@ -31,7 +31,6 @@ const ResilientConnectorFields: React.FC { // TODO: remove incidentConfiguration later, when Case Resilient will move their fields to the level of action execution const { apiUrl, orgId, incidentConfiguration, isCaseOwned } = action.config; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx index b12307e1fdd1..cb9d96511abd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.test.tsx @@ -6,16 +6,12 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import ResilientParamsFields from './resilient_params'; -import { DocLinksStart } from 'kibana/public'; - import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; -import { coreMock } from 'src/core/public/mocks'; - -const mocks = coreMock.createSetup(); jest.mock('./use_get_incident_types'); jest.mock('./use_get_severity'); +jest.mock('../../../../common/lib/kibana'); const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; @@ -87,9 +83,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[{ name: 'alertId', description: '' }]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -113,9 +106,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -132,9 +122,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -157,9 +144,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -179,9 +163,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); @@ -204,9 +185,6 @@ describe('ResilientParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} actionConnector={connector} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index 996e83b87f05..194dbe671244 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -27,6 +27,7 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; import { extractActionVariable } from '../extract_action_variable'; +import { useKibana } from '../../../../common/lib/kibana'; const ResilientParamsFields: React.FunctionComponent> = ({ actionParams, @@ -35,9 +36,11 @@ const ResilientParamsFields: React.FunctionComponent { + const { + http, + notifications: { toasts }, + } = useKibana().services; const [firstLoad, setFirstLoad] = useState(false); const { title, description, comments, incidentTypes, severityCode, savedObjectId } = actionParams.subActionParams || {}; @@ -65,13 +68,13 @@ const ResilientParamsFields: React.FunctionComponent { - const mocks = coreMock.createSetup(); const editAction = jest.fn(); - test('all params fields is rendered', () => { const actionParams = { level: ServerLogLevelOptions.TRACE, @@ -26,9 +22,6 @@ describe('ServerLogParamsFields renders', () => { editAction={editAction} index={0} defaultMessage={'test default message'} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(editAction).not.toHaveBeenCalled(); @@ -50,9 +43,6 @@ describe('ServerLogParamsFields renders', () => { errors={{ message: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 8840045d6b72..de48e62d88aa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { DocLinksStart } from 'kibana/public'; import ServiceNowConnectorFields from './servicenow_connectors'; import { ServiceNowActionConnector } from './types'; +jest.mock('../../../../common/lib/kibana'); describe('ServiceNowActionConnectorFields renders', () => { test('alerting servicenow connector fields is rendered', () => { @@ -24,16 +24,12 @@ describe('ServiceNowActionConnectorFields renders', () => { apiUrl: 'https://test/', }, } as ServiceNowActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -63,16 +59,12 @@ describe('ServiceNowActionConnectorFields renders', () => { isCaseOwned: true, }, } as ServiceNowActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} consumer={'case'} /> @@ -91,16 +83,12 @@ describe('ServiceNowActionConnectorFields renders', () => { config: {}, secrets: {}, } as ServiceNowActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -122,16 +110,12 @@ describe('ServiceNowActionConnectorFields renders', () => { apiUrl: 'https://test/', }, } as ServiceNowActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 06edb22f1c4c..328667ae49c6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -26,10 +26,13 @@ import { CasesConfigurationMapping, FieldMapping, createDefaultMapping } from '. import * as i18n from './translations'; import { ServiceNowActionConnector } from './types'; import { connectorConfiguration } from './config'; +import { useKibana } from '../../../../common/lib/kibana'; const ServiceNowConnectorFields: React.FC< ActionConnectorFieldsProps -> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly, docLinks }) => { +> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => { + const { docLinks } = useKibana().services; + // TODO: remove incidentConfiguration later, when Case ServiceNow will move their fields to the level of action execution const { apiUrl, incidentConfiguration, isCaseOwned } = action.config; const mapping = incidentConfiguration ? incidentConfiguration.mapping : []; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx index 88f76c760bdc..e9d192b47220 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx @@ -6,12 +6,9 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import ServiceNowParamsFields from './servicenow_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; describe('ServiceNowParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { subAction: 'pushToService', subActionParams: { @@ -33,9 +30,6 @@ describe('ServiceNowParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[{ name: 'alertId', description: '' }]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="urgencySelect"]').length > 0).toBeTruthy(); @@ -52,7 +46,6 @@ describe('ServiceNowParamsFields renders', () => { }); test('the savedObjectId fields is rendered if we cant find an alertId in the messageVariables', () => { - const mocks = coreMock.createSetup(); const actionParams = { subAction: 'pushToService', subActionParams: { @@ -74,9 +67,6 @@ describe('ServiceNowParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx index 54ed912d6359..f93219265501 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx @@ -8,7 +8,7 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { act } from '@testing-library/react'; import { SlackActionConnector } from '../types'; import SlackActionFields from './slack_connectors'; -import { DocLinksStart } from 'kibana/public'; +jest.mock('../../../../common/lib/kibana'); describe('SlackActionFields renders', () => { test('all connector fields is rendered', async () => { @@ -21,16 +21,12 @@ describe('SlackActionFields renders', () => { name: 'email', config: {}, } as SlackActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -51,16 +47,12 @@ describe('SlackActionFields renders', () => { config: {}, secrets: {}, } as SlackActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -78,16 +70,12 @@ describe('SlackActionFields renders', () => { name: 'email', config: {}, } as SlackActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx index d146e0c7a009..714b27178d3c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -9,10 +9,12 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; import { SlackActionConnector } from '../types'; +import { useKibana } from '../../../../common/lib/kibana'; const SlackActionFields: React.FunctionComponent< ActionConnectorFieldsProps -> = ({ action, editActionSecrets, errors, readOnly, docLinks }) => { +> = ({ action, editActionSecrets, errors, readOnly }) => { + const { docLinks } = useKibana().services; const { webhookUrl } = action.secrets; return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx index cf3567772122..afaa7ae2bc71 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx @@ -6,12 +6,9 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import SlackParamsFields from './slack_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; describe('SlackParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { message: 'test message', }; @@ -22,9 +19,6 @@ describe('SlackParamsFields renders', () => { errors={{ message: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="messageTextArea"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx index eaa7159db6a3..69076827137b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.test.tsx @@ -8,7 +8,7 @@ import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { act } from '@testing-library/react'; import { TeamsActionConnector } from '../types'; import TeamsActionFields from './teams_connectors'; -import { DocLinksStart } from 'kibana/public'; +jest.mock('../../../../common/lib/kibana'); describe('TeamsActionFields renders', () => { test('all connector fields are rendered', async () => { @@ -21,16 +21,12 @@ describe('TeamsActionFields renders', () => { name: 'teams', config: {}, } as TeamsActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -51,16 +47,12 @@ describe('TeamsActionFields renders', () => { config: {}, secrets: {}, } as TeamsActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); @@ -78,16 +70,12 @@ describe('TeamsActionFields renders', () => { name: 'teams', config: {}, } as TeamsActionConnector; - const deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, - }; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} - docLinks={deps!.docLinks} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx index 7de0df332979..4e61bffa5ade 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx @@ -9,11 +9,13 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; import { TeamsActionConnector } from '../types'; +import { useKibana } from '../../../../common/lib/kibana'; const TeamsActionFields: React.FunctionComponent< ActionConnectorFieldsProps -> = ({ action, editActionSecrets, errors, readOnly, docLinks }) => { +> = ({ action, editActionSecrets, errors, readOnly }) => { const { webhookUrl } = action.secrets; + const { docLinks } = useKibana().services; return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx index 02ad3e33a28e..f82ff2cf47de 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.test.tsx @@ -6,12 +6,10 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import TeamsParamsFields from './teams_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; +jest.mock('../../../../common/lib/kibana'); describe('TeamsParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { message: 'test message', }; @@ -22,9 +20,6 @@ describe('TeamsParamsFields renders', () => { errors={{ message: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="messageTextArea"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx index a6a06287b73f..b83a904c2477 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx @@ -7,7 +7,6 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { WebhookActionConnector } from '../types'; import WebhookActionConnectorFields from './webhook_connectors'; -import { DocLinksStart } from 'kibana/public'; describe('WebhookActionConnectorFields renders', () => { test('all connector fields is rendered', () => { @@ -33,7 +32,6 @@ describe('WebhookActionConnectorFields renders', () => { errors={{ url: [], method: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); @@ -61,7 +59,6 @@ describe('WebhookActionConnectorFields renders', () => { errors={{ url: [], method: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); @@ -92,7 +89,6 @@ describe('WebhookActionConnectorFields renders', () => { errors={{ url: [], method: [], user: [], password: [] }} editActionConfig={() => {}} editActionSecrets={() => {}} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx index 7dafea8a99e8..3b645f33bdde 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx @@ -6,12 +6,9 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import WebhookParamsFields from './webhook_params'; -import { DocLinksStart } from 'kibana/public'; -import { coreMock } from 'src/core/public/mocks'; describe('WebhookParamsFields renders', () => { test('all params fields is rendered', () => { - const mocks = coreMock.createSetup(); const actionParams = { body: 'test message', }; @@ -22,9 +19,6 @@ describe('WebhookParamsFields renders', () => { errors={{ body: [] }} editAction={() => {}} index={0} - docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} - toastNotifications={mocks.notifications.toasts} - http={mocks.http} /> ); expect(wrapper.find('[data-test-subj="bodyJsonEditor"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx index a093b9c51197..0220bcaf7cd9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx @@ -7,7 +7,7 @@ import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; import { HttpSetup } from 'kibana/public'; -import { useAppDependencies } from '../app_context'; +import { useKibana } from '../../common/lib/kibana'; export const DeleteModalConfirmation = ({ idsToDelete, @@ -40,7 +40,10 @@ export const DeleteModalConfirmation = ({ setDeleteModalVisibility(idsToDelete.length > 0); }, [idsToDelete]); - const { http, toastNotifications } = useAppDependencies(); + const { + http, + notifications: { toasts }, + } = useKibana().services; const numIdsToDelete = idsToDelete.length; if (!deleteModalFlyoutVisible) { return null; @@ -86,7 +89,7 @@ export const DeleteModalConfirmation = ({ const numSuccesses = successes.length; const numErrors = errors.length; if (numSuccesses > 0) { - toastNotifications.addSuccess( + toasts.addSuccess( i18n.translate( 'xpack.triggersActionsUI.components.deleteSelectedIdsSuccessNotification.descriptionText', { @@ -99,7 +102,7 @@ export const DeleteModalConfirmation = ({ } if (numErrors > 0) { - toastNotifications.addDanger( + toasts.addDanger( i18n.translate( 'xpack.triggersActionsUI.components.deleteSelectedIdsErrorNotification.descriptionText', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx deleted file mode 100644 index bb0606db2a9b..000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/actions_connectors_context.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { createContext, useContext } from 'react'; -import { HttpSetup, ApplicationStart, DocLinksStart, ToastsSetup } from 'kibana/public'; -import { ActionTypeRegistryContract, ActionConnector } from '../../types'; - -export interface ActionsConnectorsContextValue { - http: HttpSetup; - actionTypeRegistry: ActionTypeRegistryContract; - toastNotifications: ToastsSetup; - capabilities: ApplicationStart['capabilities']; - reloadConnectors?: () => Promise; - docLinks: DocLinksStart; - consumer?: string; -} - -const ActionsConnectorsContext = createContext(null as any); - -export const ActionsConnectorsContextProvider = ({ - children, - value, -}: { - value: ActionsConnectorsContextValue; - children: React.ReactNode; -}) => { - return ( - {children} - ); -}; - -export const useActionsConnectorsContext = () => { - const ctx = useContext(ActionsConnectorsContext); - if (!ctx) { - throw new Error('ActionsConnectorsContext has not been set.'); - } - return ctx; -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.test.tsx index 80cebeb05572..435be117b513 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.test.tsx @@ -8,15 +8,13 @@ import * as React from 'react'; import { RouteComponentProps, Router } from 'react-router-dom'; import { createMemoryHistory, createLocation } from 'history'; import { mountWithIntl } from '@kbn/test/jest'; - import TriggersActionsUIHome, { MatchParams } from './home'; -import { AppContextProvider } from './app_context'; -import { getMockedAppDependencies } from './test_utils'; +import { useKibana } from '../common/lib/kibana'; +jest.mock('../common/lib/kibana'); +const useKibanaMock = useKibana as jest.Mocked; describe('home', () => { it('renders the documentation link', async () => { - const deps = await getMockedAppDependencies(); - const props: RouteComponentProps = { history: createMemoryHistory(), location: createLocation('/'), @@ -29,11 +27,10 @@ describe('home', () => { }, }, }; + const wrapper = mountWithIntl( - - - - + + ); const documentationLink = wrapper.find('[data-test-subj="documentationLink"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx index 450f33d4f7e8..97faef6d4996 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx @@ -23,13 +23,13 @@ import { import { Section, routeToConnectors, routeToAlerts } from './constants'; import { getAlertingSectionBreadcrumb } from './lib/breadcrumb'; import { getCurrentDocTitle } from './lib/doc_title'; -import { useAppDependencies } from './app_context'; import { hasShowActionsCapability } from './lib/capabilities'; import { ActionsConnectorsList } from './sections/actions_connectors_list/components/actions_connectors_list'; import { AlertsList } from './sections/alerts_list/components/alerts_list'; import { HealthCheck } from './components/health_check'; import { HealthContextProvider } from './context/health_context'; +import { useKibana } from '../common/lib/kibana'; export interface MatchParams { section: Section; @@ -41,7 +41,13 @@ export const TriggersActionsUIHome: React.FunctionComponent { - const { chrome, capabilities, setBreadcrumbs, docLinks, http } = useAppDependencies(); + const { + chrome, + application: { capabilities }, + setBreadcrumbs, + docLinks, + http, + } = useKibana().services; const canShowActions = hasShowActionsCapability(capabilities); const tabs: Array<{ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index cc42c6296e7b..f6164b1856bb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -5,29 +5,13 @@ */ import * as React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, UserConfiguredActionConnector } from '../../../types'; import { ActionConnectorForm } from './action_connector_form'; const actionTypeRegistry = actionTypeRegistryMock.create(); +jest.mock('../../../common/lib/kibana'); describe('action_connector_form', () => { - let deps: any; - beforeAll(async () => { - const mocks = coreMock.createSetup(); - const [ - { - application: { capabilities }, - }, - ] = await mocks.getStartServices(); - deps = { - http: mocks.http, - actionTypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - capabilities, - }; - }); - it('renders action_connector_form', () => { const actionType = { id: 'my-action-type', @@ -54,21 +38,15 @@ describe('action_connector_form', () => { secrets: {}, isPreconfigured: false, }; - let wrapper; - if (deps) { - wrapper = mountWithIntl( - {}} - errors={{ name: [] }} - http={deps!.http} - actionTypeRegistry={deps!.actionTypeRegistry} - docLinks={deps!.docLinks} - capabilities={deps!.capabilities} - /> - ); - } + const wrapper = mountWithIntl( + {}} + errors={{ name: [] }} + actionTypeRegistry={actionTypeRegistry} + /> + ); const connectorNameField = wrapper?.find('[data-test-subj="nameInput"]'); expect(connectorNameField?.exists()).toBeTruthy(); expect(connectorNameField?.first().prop('value')).toBe(''); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 53121e5249ab..7d8949421126 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -20,7 +20,6 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { HttpSetup, ApplicationStart, DocLinksStart } from 'kibana/public'; import { ReducerAction } from './connector_reducer'; import { ActionConnector, @@ -29,6 +28,7 @@ import { UserConfiguredActionConnector, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { useKibana } from '../../../common/lib/kibana'; export function validateBaseProperties(actionObject: ActionConnector) { const validationResult = { errors: {} }; @@ -60,10 +60,7 @@ interface ActionConnectorProps< body: { message: string; error: string }; }; errors: IErrorObject; - http: HttpSetup; actionTypeRegistry: ActionTypeRegistryContract; - docLinks: DocLinksStart; - capabilities: ApplicationStart['capabilities']; consumer?: string; } @@ -73,12 +70,13 @@ export const ActionConnectorForm = ({ actionTypeName, serverError, errors, - http, actionTypeRegistry, - docLinks, - capabilities, consumer, }: ActionConnectorProps) => { + const { + docLinks, + application: { capabilities }, + } = useKibana().services; const canSave = hasSaveActionsCapability(capabilities); const setActionProperty = (key: string, value: any) => { @@ -196,8 +194,6 @@ export const ActionConnectorForm = ({ readOnly={!canSave} editActionConfig={setActionConfigProperty} editActionSecrets={setActionSecretsProperty} - http={http} - docLinks={docLinks} consumer={consumer} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 38c9687ae581..5b56720737b7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -11,14 +11,14 @@ import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, Alert, AlertAction } from '../../../types'; import ActionForm from './action_form'; import { ResolvedActionGroup } from '../../../../../alerts/common'; +import { useKibana } from '../../../common/lib/kibana'; +jest.mock('../../../common/lib/kibana'); jest.mock('../../lib/action_connector_api', () => ({ loadAllActions: jest.fn(), loadActionTypes: jest.fn(), })); -const actionTypeRegistry = actionTypeRegistryMock.create(); +const setHasActionsWithBrokenConnector = jest.fn(); describe('action_form', () => { - let deps: any; - const mockedActionParamsFields = lazy(async () => ({ default() { return ; @@ -110,9 +110,12 @@ describe('action_form', () => { actionConnectorFields: null, actionParamsFields: null, }; + const useKibanaMock = useKibana as jest.Mocked; describe('action_form in alert', () => { async function setup(customActions?: AlertAction[]) { + const actionTypeRegistry = actionTypeRegistryMock.create(); + const { loadAllActions } = jest.requireMock('../../lib/action_connector_api'); loadAllActions.mockResolvedValueOnce([ { @@ -164,20 +167,14 @@ describe('action_form', () => { application: { capabilities }, }, ] = await mocks.getStartServices(); - deps = { - toastNotifications: mocks.notifications.toasts, - http: mocks.http, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: true, + delete: true, }, - setHasActionsWithBrokenConnector: jest.fn(), - actionTypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; actionTypeRegistry.list.mockReturnValue([ actionType, @@ -188,7 +185,6 @@ describe('action_form', () => { ]); actionTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.get.mockReturnValue(actionType); - const initialAlert = ({ name: 'test', params: {}, @@ -241,9 +237,8 @@ describe('action_form', () => { setActionParamsProperty={(key: string, value: any, index: number) => (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) } - setHasActionsWithBrokenConnector={deps!.setHasActionsWithBrokenConnector} - http={deps!.http} - actionTypeRegistry={deps!.actionTypeRegistry} + actionTypeRegistry={actionTypeRegistry} + setHasActionsWithBrokenConnector={setHasActionsWithBrokenConnector} defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'} actionTypes={[ { @@ -295,9 +290,6 @@ describe('action_form', () => { minimumLicenseRequired: 'basic', }, ]} - toastNotifications={deps!.toastNotifications} - docLinks={deps.docLinks} - capabilities={deps.capabilities} /> ); @@ -321,7 +313,7 @@ describe('action_form', () => { .find(`EuiToolTip [data-test-subj="${actionType.id}-ActionTypeSelectOption"]`) .exists() ).toBeFalsy(); - expect(deps.setHasActionsWithBrokenConnector).toHaveBeenLastCalledWith(false); + expect(setHasActionsWithBrokenConnector).toHaveBeenLastCalledWith(false); }); it('does not render action types disabled by config', async () => { @@ -490,7 +482,7 @@ describe('action_form', () => { }, }, ]); - expect(deps.setHasActionsWithBrokenConnector).toHaveBeenLastCalledWith(true); + expect(setHasActionsWithBrokenConnector).toHaveBeenLastCalledWith(true); }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 83e6386122eb..d62b8e769408 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -18,16 +18,15 @@ import { EuiToolTip, EuiLink, } from '@elastic/eui'; -import { HttpSetup, ToastsSetup, ApplicationStart, DocLinksStart } from 'kibana/public'; import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api'; import { ActionTypeModel, - ActionTypeRegistryContract, AlertAction, ActionTypeIndex, ActionConnector, ActionType, ActionVariables, + ActionTypeRegistryContract, } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; import { ConnectorAddModal } from './connector_add_modal'; @@ -37,6 +36,7 @@ import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; import { ActionGroup, AlertActionParam } from '../../../../../alerts/common'; +import { useKibana } from '../../../common/lib/kibana'; export interface ActionAccordionFormProps { actions: AlertAction[]; @@ -46,16 +46,12 @@ export interface ActionAccordionFormProps { setActionGroupIdByIndex?: (group: string, index: number) => void; setAlertProperty: (actions: AlertAction[]) => void; setActionParamsProperty: (key: string, value: AlertActionParam, index: number) => void; - http: HttpSetup; - actionTypeRegistry: ActionTypeRegistryContract; - toastNotifications: ToastsSetup; - docLinks: DocLinksStart; actionTypes?: ActionType[]; messageVariables?: ActionVariables; defaultActionMessage?: string; setHasActionsDisabled?: (value: boolean) => void; setHasActionsWithBrokenConnector?: (value: boolean) => void; - capabilities: ApplicationStart['capabilities']; + actionTypeRegistry: ActionTypeRegistryContract; } interface ActiveActionConnectorState { @@ -71,17 +67,17 @@ export const ActionForm = ({ setActionGroupIdByIndex, setAlertProperty, setActionParamsProperty, - http, - actionTypeRegistry, actionTypes, messageVariables, defaultActionMessage, - toastNotifications, setHasActionsDisabled, setHasActionsWithBrokenConnector, - capabilities, - docLinks, + actionTypeRegistry, }: ActionAccordionFormProps) => { + const { + http, + notifications: { toasts }, + } = useKibana().services; const [addModalVisible, setAddModalVisibility] = useState(false); const [activeActionItem, setActiveActionItem] = useState( undefined @@ -111,7 +107,7 @@ export const ActionForm = ({ } setActionTypesIndex(index); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionTypesMessage', { defaultMessage: 'Unable to load action types' } @@ -132,7 +128,7 @@ export const ActionForm = ({ const loadedConnectors = await loadConnectors({ http }); setConnectors(loadedConnectors); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertForm.unableToLoadActionsMessage', { @@ -181,7 +177,7 @@ export const ActionForm = ({ function addActionType(actionTypeModel: ActionTypeModel) { if (!defaultActionGroupId) { - toastNotifications!.addDanger({ + toasts!.addDanger({ title: i18n.translate('xpack.triggersActionsUI.sections.alertForm.unableToAddAction', { defaultMessage: 'Unable to add action, because default action group is not defined', }), @@ -302,7 +298,6 @@ export const ActionForm = ({ key={`action-form-action-at-${index}`} actionTypeRegistry={actionTypeRegistry} defaultActionGroupId={defaultActionGroupId} - capabilities={capabilities} emptyActionsIds={emptyActionsIds} onDeleteConnector={() => { const updatedActions = actions.filter( @@ -337,11 +332,6 @@ export const ActionForm = ({ setActionParamsProperty={setActionParamsProperty} actionTypesIndex={actionTypesIndex} connectors={connectors} - http={http} - toastNotifications={toastNotifications} - docLinks={docLinks} - capabilities={capabilities} - actionTypeRegistry={actionTypeRegistry} defaultActionGroupId={defaultActionGroupId} defaultActionMessage={defaultActionMessage} messageVariables={messageVariables} @@ -354,6 +344,7 @@ export const ActionForm = ({ onConnectorSelected={(id: string) => { setActionIdByIndex(id, index); }} + actionTypeRegistry={actionTypeRegistry} onDeleteAction={() => { const updatedActions = actions.filter( (_item: AlertAction, i: number) => i !== index @@ -442,10 +433,6 @@ export const ActionForm = ({ setActionIdByIndex(savedAction.id, activeActionItem.index); }} actionTypeRegistry={actionTypeRegistry} - http={http} - toastNotifications={toastNotifications} - docLinks={docLinks} - capabilities={capabilities} /> ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 10c8498b181d..bd0e4b164531 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -34,12 +34,14 @@ import { ActionConnector, ActionVariables, ActionVariable, + ActionTypeRegistryContract, } from '../../../types'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { ActionAccordionFormProps } from './action_form'; import { transformActionVariables } from '../../lib/action_variables'; import { resolvedActionGroupMessage } from '../../constants'; +import { useKibana } from '../../../common/lib/kibana'; export type ActionTypeFormProps = { actionItem: AlertAction; @@ -54,19 +56,15 @@ export type ActionTypeFormProps = { setActionParamsProperty: (key: string, value: AlertActionParam, index: number) => void; actionTypesIndex: ActionTypeIndex; connectors: ActionConnector[]; + actionTypeRegistry: ActionTypeRegistryContract; } & Pick< ActionAccordionFormProps, | 'defaultActionGroupId' | 'actionGroups' | 'setActionGroupIdByIndex' | 'setActionParamsProperty' - | 'http' - | 'actionTypeRegistry' - | 'toastNotifications' - | 'docLinks' | 'messageVariables' | 'defaultActionMessage' - | 'capabilities' >; const preconfiguredMessage = i18n.translate( @@ -87,17 +85,16 @@ export const ActionTypeForm = ({ setActionParamsProperty, actionTypesIndex, connectors, - http, - toastNotifications, - docLinks, - capabilities, - actionTypeRegistry, defaultActionGroupId, defaultActionMessage, messageVariables, actionGroups, setActionGroupIdByIndex, + actionTypeRegistry, }: ActionTypeFormProps) => { + const { + application: { capabilities }, + } = useKibana().services; const [isOpen, setIsOpen] = useState(true); const [availableActionVariables, setAvailableActionVariables] = useState([]); const [availableDefaultActionMessage, setAvailableDefaultActionMessage] = useState< @@ -272,9 +269,6 @@ export const ActionTypeForm = ({ editAction={setActionParamsProperty} messageVariables={availableActionVariables} defaultMessage={availableDefaultActionMessage} - docLinks={docLinks} - http={http} - toastNotifications={toastNotifications} actionConnector={actionConnector} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 8102b4d393a1..2343ea1036ed 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -6,15 +6,15 @@ import * as React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ActionTypeMenu } from './action_type_menu'; import { ValidationResult } from '../../../types'; +import { useKibana } from '../../../common/lib/kibana'; +jest.mock('../../../common/lib/kibana'); const actionTypeRegistry = actionTypeRegistryMock.create(); +const useKibanaMock = useKibana as jest.Mocked; describe('connector_add_flyout', () => { - let deps: any; - beforeAll(async () => { const mockes = coreMock.createSetup(); const [ @@ -22,19 +22,13 @@ describe('connector_add_flyout', () => { application: { capabilities }, }, ] = await mockes.getStartServices(); - deps = { - http: mockes.http, - toastNotifications: mockes.notifications.toasts, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: true, + delete: true, }, - actionTypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); @@ -57,32 +51,20 @@ describe('connector_add_flyout', () => { actionTypeRegistry.get.mockReturnValueOnce(actionType); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + - - + ]} + actionTypeRegistry={actionTypeRegistry} + /> ); expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); @@ -107,32 +89,20 @@ describe('connector_add_flyout', () => { actionTypeRegistry.get.mockReturnValueOnce(actionType); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + - - + ]} + actionTypeRegistry={actionTypeRegistry} + /> ); expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeFalsy(); @@ -157,32 +127,20 @@ describe('connector_add_flyout', () => { actionTypeRegistry.get.mockReturnValueOnce(actionType); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + - - + ]} + actionTypeRegistry={actionTypeRegistry} + /> ); expect(wrapper.find('EuiToolTip [data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index 7ecb833fdfc9..7cd95c92b22a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -7,24 +7,29 @@ import React, { useEffect, useState } from 'react'; import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EuiToolTip } from '@elastic/eui'; -import { ActionType, ActionTypeIndex } from '../../../types'; +import { ActionType, ActionTypeIndex, ActionTypeRegistryContract } from '../../../types'; import { loadActionTypes } from '../../lib/action_connector_api'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled'; +import { useKibana } from '../../../common/lib/kibana'; interface Props { onActionTypeChange: (actionType: ActionType) => void; actionTypes?: ActionType[]; setHasActionsUpgradeableByTrial?: (value: boolean) => void; + actionTypeRegistry: ActionTypeRegistryContract; } export const ActionTypeMenu = ({ onActionTypeChange, actionTypes, setHasActionsUpgradeableByTrial, + actionTypeRegistry, }: Props) => { - const { http, toastNotifications, actionTypeRegistry } = useActionsConnectorsContext(); + const { + http, + notifications: { toasts }, + } = useKibana().services; const [actionTypesIndex, setActionTypesIndex] = useState(undefined); useEffect(() => { @@ -47,8 +52,8 @@ export const ActionTypeMenu = ({ setHasActionsUpgradeableByTrial(hasActionsUpgradeableByTrial); } } catch (e) { - if (toastNotifications) { - toastNotifications.addDanger({ + if (toasts) { + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionTypesMessage', { defaultMessage: 'Unable to load action types' } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index 68d5d1e7d957..4ffb97f01915 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -7,15 +7,15 @@ import * as React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import ConnectorAddFlyout from './connector_add_flyout'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; +import { useKibana } from '../../../common/lib/kibana'; +jest.mock('../../../common/lib/kibana'); const actionTypeRegistry = actionTypeRegistryMock.create(); +const useKibanaMock = useKibana as jest.Mocked; describe('connector_add_flyout', () => { - let deps: any; - beforeAll(async () => { const mocks = coreMock.createSetup(); const [ @@ -23,19 +23,13 @@ describe('connector_add_flyout', () => { application: { capabilities }, }, ] = await mocks.getStartServices(); - deps = { - toastNotifications: mocks.notifications.toasts, - http: mocks.http, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: true, + delete: true, }, - actionTypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); @@ -45,32 +39,23 @@ describe('connector_add_flyout', () => { actionTypeRegistry.has.mockReturnValue(true); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', }, - docLinks: deps!.docLinks, + ]} + reloadConnectors={() => { + return new Promise(() => {}); }} - > - {}} - actionTypes={[ - { - id: actionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - ]} - /> - + actionTypeRegistry={actionTypeRegistry} + /> ); expect(wrapper.find('ActionTypeMenu')).toHaveLength(1); expect(wrapper.find(`[data-test-subj="${actionType.id}-card"]`).exists()).toBeTruthy(); @@ -86,40 +71,31 @@ describe('connector_add_flyout', () => { actionTypeRegistry.has.mockReturnValue(true); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + { + id: disabledActionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: false, + minimumLicenseRequired: 'gold', }, - docLinks: deps!.docLinks, + ]} + reloadConnectors={() => { + return new Promise(() => {}); }} - > - {}} - actionTypes={[ - { - id: actionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - { - id: disabledActionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: false, - minimumLicenseRequired: 'gold', - }, - ]} - /> - + actionTypeRegistry={actionTypeRegistry} + /> ); const callout = wrapper.find('UpgradeYourLicenseCallOut'); expect(callout).toHaveLength(1); @@ -145,40 +121,31 @@ describe('connector_add_flyout', () => { actionTypeRegistry.has.mockReturnValue(true); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', }, - docLinks: deps!.docLinks, + { + id: disabledActionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: false, + minimumLicenseRequired: 'platinum', + }, + ]} + reloadConnectors={() => { + return new Promise(() => {}); }} - > - {}} - actionTypes={[ - { - id: actionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - { - id: disabledActionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: false, - minimumLicenseRequired: 'platinum', - }, - ]} - /> - + actionTypeRegistry={actionTypeRegistry} + /> ); const callout = wrapper.find('UpgradeYourLicenseCallOut'); expect(callout).toHaveLength(0); @@ -192,40 +159,31 @@ describe('connector_add_flyout', () => { actionTypeRegistry.has.mockReturnValue(true); const wrapper = mountWithIntl( - { - return new Promise(() => {}); + {}} + actionTypes={[ + { + id: actionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + { + id: disabledActionType.id, + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: false, + minimumLicenseRequired: 'enterprise', }, - docLinks: deps!.docLinks, + ]} + reloadConnectors={() => { + return new Promise(() => {}); }} - > - {}} - actionTypes={[ - { - id: actionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'basic', - }, - { - id: disabledActionType.id, - enabled: true, - name: 'Test', - enabledInConfig: true, - enabledInLicense: false, - minimumLicenseRequired: 'enterprise', - }, - ]} - /> - + actionTypeRegistry={actionTypeRegistry} + /> ); const callout = wrapper.find('UpgradeYourLicenseCallOut'); expect(callout).toHaveLength(0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index b53d0816ea06..618f59726f38 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -24,34 +24,41 @@ import { HttpSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { ActionTypeMenu } from './action_type_menu'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; -import { ActionType, ActionConnector, IErrorObject } from '../../../types'; +import { + ActionType, + ActionConnector, + IErrorObject, + ActionTypeRegistryContract, +} from '../../../types'; import { connectorReducer } from './connector_reducer'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; export interface ConnectorAddFlyoutProps { onClose: () => void; actionTypes?: ActionType[]; onTestConnector?: (connector: ActionConnector) => void; + reloadConnectors?: () => Promise; + consumer?: string; + actionTypeRegistry: ActionTypeRegistryContract; } -export const ConnectorAddFlyout = ({ +const ConnectorAddFlyout: React.FunctionComponent = ({ onClose, actionTypes, onTestConnector, -}: ConnectorAddFlyoutProps) => { + reloadConnectors, + consumer, + actionTypeRegistry, +}) => { let hasErrors = false; const { http, - toastNotifications, - capabilities, - actionTypeRegistry, - reloadConnectors, - docLinks, - consumer, - } = useActionsConnectorsContext(); + notifications: { toasts }, + application: { capabilities }, + } = useKibana().services; const [actionType, setActionType] = useState(undefined); const [hasActionsUpgradeableByTrial, setHasActionsUpgradeableByTrial] = useState(false); @@ -90,6 +97,7 @@ export const ConnectorAddFlyout = ({ onActionTypeChange={onActionTypeChange} actionTypes={actionTypes} setHasActionsUpgradeableByTrial={setHasActionsUpgradeableByTrial} + actionTypeRegistry={actionTypeRegistry} /> ); } else { @@ -108,19 +116,15 @@ export const ConnectorAddFlyout = ({ dispatch={dispatch} errors={errors} actionTypeRegistry={actionTypeRegistry} - http={http} - docLinks={docLinks} - capabilities={capabilities} consumer={consumer} /> ); } - const onActionConnectorSave = async (): Promise => await createActionConnector({ http, connector }) .then((savedConnector) => { - if (toastNotifications) { - toastNotifications.addSuccess( + if (toasts) { + toasts.addSuccess( i18n.translate( 'xpack.triggersActionsUI.sections.addConnectorForm.updateSuccessNotificationText', { @@ -135,7 +139,7 @@ export const ConnectorAddFlyout = ({ return savedConnector; }) .catch((errorRes) => { - toastNotifications.addDanger( + toasts.addDanger( errorRes.body?.message ?? i18n.translate( 'xpack.triggersActionsUI.sections.addConnectorForm.updateErrorNotificationText', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx index 97baf4a36cb4..95b8741c17a7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx @@ -22,6 +22,7 @@ import { import { AlertAction, ActionTypeIndex } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { ActionAccordionFormProps } from './action_form'; +import { useKibana } from '../../../common/lib/kibana'; type AddConnectorInFormProps = { actionTypesIndex: ActionTypeIndex; @@ -30,7 +31,7 @@ type AddConnectorInFormProps = { onAddConnector: () => void; onDeleteConnector: () => void; emptyActionsIds: string[]; -} & Pick; +} & Pick; export const AddConnectorInline = ({ actionTypesIndex, @@ -41,8 +42,10 @@ export const AddConnectorInline = ({ actionTypeRegistry, emptyActionsIds, defaultActionGroupId, - capabilities, }: AddConnectorInFormProps) => { + const { + application: { capabilities }, + } = useKibana().services; const canSave = hasSaveActionsCapability(capabilities); const actionTypeName = actionTypesIndex diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index 0d634729002e..31e4fdd4f450 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -5,35 +5,31 @@ */ import * as React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; import { ConnectorAddModal } from './connector_add_modal'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, ActionType } from '../../../types'; +import { useKibana } from '../../../common/lib/kibana'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; + +jest.mock('../../../common/lib/kibana'); +const mocks = coreMock.createSetup(); const actionTypeRegistry = actionTypeRegistryMock.create(); +const useKibanaMock = useKibana as jest.Mocked; describe('connector_add_modal', () => { - let deps: any; - beforeAll(async () => { - const mocks = coreMock.createSetup(); const [ { application: { capabilities }, }, ] = await mocks.getStartServices(); - deps = { - toastNotifications: mocks.notifications.toasts, - http: mocks.http, - capabilities: { - ...capabilities, - actions: { - show: true, - save: true, - delete: true, - }, + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: true, + delete: true, }, - actionTypeRegistry, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); it('renders connector modal form if addModalVisible is true', () => { @@ -67,11 +63,7 @@ describe('connector_add_modal', () => { {}} actionType={actionType} - http={deps!.http} - actionTypeRegistry={deps!.actionTypeRegistry} - toastNotifications={deps!.toastNotifications} - docLinks={deps!.docLinks} - capabilities={deps!.capabilities} + actionTypeRegistry={actionTypeRegistry} /> ); expect(wrapper.exists('.euiModalHeader')).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index a2a2d1234dbc..d63dd5d2985b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -3,7 +3,7 @@ * 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, { useCallback, useReducer, useState } from 'react'; +import React, { useCallback, useMemo, useReducer, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, EuiFlexItem, EuiIcon, EuiFlexGroup } from '@elastic/eui'; import { @@ -17,7 +17,6 @@ import { import { EuiButtonEmpty } from '@elastic/eui'; import { EuiOverlayMask } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { HttpSetup, ToastsApi, ApplicationStart, DocLinksStart } from 'kibana/public'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; import { connectorReducer } from './connector_reducer'; import { createActionConnector } from '../../lib/action_connector_api'; @@ -29,40 +28,37 @@ import { IErrorObject, ActionTypeRegistryContract, } from '../../../types'; +import { useKibana } from '../../../common/lib/kibana'; interface ConnectorAddModalProps { actionType: ActionType; onClose: () => void; postSaveEventHandler?: (savedAction: ActionConnector) => void; - http: HttpSetup; - actionTypeRegistry: ActionTypeRegistryContract; - toastNotifications: Pick< - ToastsApi, - 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' - >; - capabilities: ApplicationStart['capabilities']; - docLinks: DocLinksStart; consumer?: string; + actionTypeRegistry: ActionTypeRegistryContract; } export const ConnectorAddModal = ({ actionType, onClose, postSaveEventHandler, - http, - toastNotifications, - actionTypeRegistry, - capabilities, - docLinks, consumer, + actionTypeRegistry, }: ConnectorAddModalProps) => { + const { + http, + notifications: { toasts }, + application: { capabilities }, + } = useKibana().services; let hasErrors = false; - // eslint-disable-next-line react-hooks/exhaustive-deps - const initialConnector = { - actionTypeId: actionType.id, - config: {}, - secrets: {}, - } as ActionConnector; + const initialConnector = useMemo( + () => ({ + actionTypeId: actionType.id, + config: {}, + secrets: {}, + }), + [actionType.id] + ); const [isSaving, setIsSaving] = useState(false); const canSave = hasSaveActionsCapability(capabilities); @@ -93,8 +89,8 @@ export const ConnectorAddModal = ({ const onActionConnectorSave = async (): Promise => await createActionConnector({ http, connector }) .then((savedConnector) => { - if (toastNotifications) { - toastNotifications.addSuccess( + if (toasts) { + toasts.addSuccess( i18n.translate( 'xpack.triggersActionsUI.sections.addModalConnectorForm.updateSuccessNotificationText', { @@ -149,9 +145,6 @@ export const ConnectorAddModal = ({ serverError={serverError} errors={errors} actionTypeRegistry={actionTypeRegistry} - docLinks={docLinks} - http={http} - capabilities={capabilities} consumer={consumer} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index 5833c3a9a37b..581db16e9a13 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -6,14 +6,14 @@ import * as React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult } from '../../../types'; import ConnectorEditFlyout from './connector_edit_flyout'; -import { AppContextProvider } from '../../app_context'; +import { useKibana } from '../../../common/lib/kibana'; +jest.mock('../../../common/lib/kibana'); const actionTypeRegistry = actionTypeRegistryMock.create(); -let deps: any; +const useKibanaMock = useKibana as jest.Mocked; describe('connector_edit_flyout', () => { beforeAll(async () => { @@ -23,21 +23,13 @@ describe('connector_edit_flyout', () => { application: { capabilities }, }, ] = await mockes.getStartServices(); - deps = { - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: false, + delete: false, }, - actionTypeRegistry, - alertTypeRegistry: {} as any, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; }); @@ -69,24 +61,16 @@ describe('connector_edit_flyout', () => { }; actionTypeRegistry.get.mockReturnValue(actionType); actionTypeRegistry.has.mockReturnValue(true); - + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; const wrapper = mountWithIntl( - - { - return new Promise(() => {}); - }, - docLinks: deps.docLinks, - }} - > - {}} /> - - + {}} + reloadConnectors={() => { + return new Promise(() => {}); + }} + actionTypeRegistry={actionTypeRegistry} + /> ); const connectorNameField = wrapper.find('[data-test-subj="nameInput"]'); @@ -122,24 +106,17 @@ describe('connector_edit_flyout', () => { }; actionTypeRegistry.get.mockReturnValue(actionType); actionTypeRegistry.has.mockReturnValue(true); + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; const wrapper = mountWithIntl( - - { - return new Promise(() => {}); - }, - docLinks: deps.docLinks, - }} - > - {}} /> - - + {}} + reloadConnectors={() => { + return new Promise(() => {}); + }} + actionTypeRegistry={actionTypeRegistry} + /> ); const preconfiguredBadge = wrapper.find('[data-test-subj="preconfiguredBadge"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index d81f30e4f364..3cf6e18e8962 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -26,21 +26,24 @@ import { i18n } from '@kbn/i18n'; import { Option, none, some } from 'fp-ts/lib/Option'; import { ActionConnectorForm, validateBaseProperties } from './action_connector_form'; import { TestConnectorForm } from './test_connector_form'; -import { ActionConnector, IErrorObject } from '../../../types'; +import { ActionConnector, ActionTypeRegistryContract, IErrorObject } from '../../../types'; import { connectorReducer } from './connector_reducer'; import { updateActionConnector, executeAction } from '../../lib/action_connector_api'; import { hasSaveActionsCapability } from '../../lib/capabilities'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { ActionTypeExecutorResult, isActionTypeExecutorResult, } from '../../../../../actions/common'; import './connector_edit_flyout.scss'; +import { useKibana } from '../../../common/lib/kibana'; -export interface ConnectorEditProps { +export interface ConnectorEditFlyoutProps { initialConnector: ActionConnector; onClose: () => void; tab?: EditConectorTabs; + reloadConnectors?: () => Promise; + consumer?: string; + actionTypeRegistry: ActionTypeRegistryContract; } export enum EditConectorTabs { @@ -52,16 +55,16 @@ export const ConnectorEditFlyout = ({ initialConnector, onClose, tab = EditConectorTabs.Configuration, -}: ConnectorEditProps) => { + reloadConnectors, + consumer, + actionTypeRegistry, +}: ConnectorEditFlyoutProps) => { const { http, - toastNotifications, - capabilities, - actionTypeRegistry, - reloadConnectors, + notifications: { toasts }, docLinks, - consumer, - } = useActionsConnectorsContext(); + application: { capabilities }, + } = useKibana().services; const canSave = hasSaveActionsCapability(capabilities); const [{ connector }, dispatch] = useReducer(connectorReducer, { @@ -105,7 +108,7 @@ export const ConnectorEditFlyout = ({ const onActionConnectorSave = async (): Promise => await updateActionConnector({ http, connector, id: connector.id }) .then((savedConnector) => { - toastNotifications.addSuccess( + toasts.addSuccess( i18n.translate( 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', { @@ -119,7 +122,7 @@ export const ConnectorEditFlyout = ({ return savedConnector; }) .catch((errorRes) => { - toastNotifications.addDanger( + toasts.addDanger( errorRes.body?.message ?? i18n.translate( 'xpack.triggersActionsUI.sections.editConnectorForm.updateErrorNotificationText', @@ -254,9 +257,6 @@ export const ConnectorEditFlyout = ({ dispatch(changes); }} actionTypeRegistry={actionTypeRegistry} - http={http} - docLinks={docLinks} - capabilities={capabilities} consumer={consumer} /> ) : ( @@ -289,6 +289,7 @@ export const ConnectorEditFlyout = ({ onExecutAction={onExecutAction} isExecutingAction={isExecutingAction} executionResult={testExecutionResult} + actionTypeRegistry={actionTypeRegistry} /> )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx index f4a1aca7f8f2..ee6f67ca1e3e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx @@ -5,14 +5,13 @@ */ import React, { lazy } from 'react'; import { I18nProvider } from '@kbn/i18n/react'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; import TestConnectorForm from './test_connector_form'; import { none, some } from 'fp-ts/lib/Option'; import { ActionConnector, ValidationResult } from '../../../types'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; -import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; import { EuiFormRow, EuiFieldText, EuiText, EuiLink, EuiForm, EuiSelect } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test/jest'; +jest.mock('../../../common/lib/kibana'); const mockedActionParamsFields = lazy(async () => ({ default() { @@ -58,29 +57,10 @@ const actionType = { actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, }; +const actionTypeRegistry = actionTypeRegistryMock.create(); +actionTypeRegistry.get.mockReturnValue(actionType); describe('test_connector_form', () => { - let deps: any; - let actionTypeRegistry; - beforeAll(async () => { - actionTypeRegistry = actionTypeRegistryMock.create(); - - const mocks = coreMock.createSetup(); - const [ - { - application: { capabilities }, - }, - ] = await mocks.getStartServices(); - deps = { - http: mocks.http, - toastNotifications: mocks.notifications.toasts, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - actionTypeRegistry, - capabilities, - }; - actionTypeRegistry.get.mockReturnValue(actionType); - }); - it('renders initially as the action form and execute button and no result', async () => { const connector = { actionTypeId: actionType.id, @@ -89,31 +69,19 @@ describe('test_connector_form', () => { } as ActionConnector; const wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - docLinks: deps!.docLinks, - }} - > - {}} - isExecutingAction={false} - onExecutAction={async () => ({ - actionId: '', - status: 'ok', - })} - executionResult={none} - /> - + {}} + isExecutingAction={false} + onExecutAction={async () => ({ + actionId: '', + status: 'ok', + })} + executionResult={none} + actionTypeRegistry={actionTypeRegistry} + /> ); const executeActionButton = wrapper?.find('[data-test-subj="executeActionButton"]'); @@ -132,34 +100,22 @@ describe('test_connector_form', () => { } as ActionConnector; const wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - docLinks: deps!.docLinks, - }} - > - {}} - isExecutingAction={false} - onExecutAction={async () => ({ - actionId: '', - status: 'ok', - })} - executionResult={some({ - actionId: '', - status: 'ok', - })} - /> - + {}} + isExecutingAction={false} + onExecutAction={async () => ({ + actionId: '', + status: 'ok', + })} + executionResult={some({ + actionId: '', + status: 'ok', + })} + actionTypeRegistry={actionTypeRegistry} + /> ); const result = wrapper?.find('[data-test-subj="executionSuccessfulResult"]'); @@ -174,36 +130,24 @@ describe('test_connector_form', () => { } as ActionConnector; const wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - docLinks: deps!.docLinks, - }} - > - {}} - isExecutingAction={false} - onExecutAction={async () => ({ - actionId: '', - status: 'error', - message: 'Error Message', - })} - executionResult={some({ - actionId: '', - status: 'error', - message: 'Error Message', - })} - /> - + {}} + isExecutingAction={false} + onExecutAction={async () => ({ + actionId: '', + status: 'error', + message: 'Error Message', + })} + executionResult={some({ + actionId: '', + status: 'error', + message: 'Error Message', + })} + actionTypeRegistry={actionTypeRegistry} + /> ); const result = wrapper?.find('[data-test-subj="executionFailureResult"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx index 4d9a327f97b0..c1a4351f384b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx @@ -20,8 +20,7 @@ import { Option, map, getOrElse } from 'fp-ts/lib/Option'; import { pipe } from 'fp-ts/lib/pipeable'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { ActionConnector } from '../../../types'; -import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { ActionConnector, ActionTypeRegistryContract } from '../../../types'; import { ActionTypeExecutorResult } from '../../../../../actions/common'; export interface ConnectorAddFlyoutProps { @@ -32,6 +31,7 @@ export interface ConnectorAddFlyoutProps { actionParams: Record; onExecutAction: () => Promise>; executionResult: Option>; + actionTypeRegistry: ActionTypeRegistryContract; } export const TestConnectorForm = ({ @@ -42,8 +42,8 @@ export const TestConnectorForm = ({ setActionParams, onExecutAction, isExecutingAction, + actionTypeRegistry, }: ConnectorAddFlyoutProps) => { - const { actionTypeRegistry, docLinks, http, toastNotifications } = useActionsConnectorsContext(); const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); const ParamsFieldsComponent = actionTypeModel.actionParamsFields; @@ -75,9 +75,6 @@ export const TestConnectorForm = ({ }) } messageVariables={[]} - docLinks={docLinks} - http={http} - toastNotifications={toastNotifications} actionConnector={connector} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 226b9de8b677..27dc4f7fd9e9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -7,15 +7,13 @@ import * as React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { ActionsConnectorsList } from './actions_connectors_list'; -import { coreMock, scopedHistoryMock } from '../../../../../../../../src/core/public/mocks'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; -import { AppContextProvider } from '../../../app_context'; -import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks'; -import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; -import { alertingPluginMock } from '../../../../../../alerts/public/mocks'; -import { featuresPluginMock } from '../../../../../../features/public/mocks'; +import { useKibana } from '../../../../common/lib/kibana'; + +jest.mock('../../../../common/lib/kibana'); import { ActionConnector } from '../../../../types'; import { times } from 'lodash'; @@ -23,16 +21,15 @@ jest.mock('../../../lib/action_connector_api', () => ({ loadAllActions: jest.fn(), loadActionTypes: jest.fn(), })); - +const useKibanaMock = useKibana as jest.Mocked; const actionTypeRegistry = actionTypeRegistryMock.create(); +const mocks = coreMock.createSetup(); +const { loadAllActions, loadActionTypes } = jest.requireMock('../../../lib/action_connector_api'); describe('actions_connectors_list component empty', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAllActions, loadActionTypes } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAllActions.mockResolvedValueOnce([]); loadActionTypes.mockResolvedValueOnce([ { @@ -44,47 +41,26 @@ describe('actions_connectors_list component empty', () => { name: 'Test2', }, ]); - const mockes = coreMock.createSetup(); + actionTypeRegistry.has.mockReturnValue(true); + const [ { - chrome, - docLinks, - application: { capabilities, navigateToApp }, + application: { capabilities }, }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities: { - ...capabilities, - actions: { - show: true, - save: true, - delete: true, - }, + ] = await mocks.getStartServices(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, }, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry, - alertTypeRegistry: {} as any, - kibanaFeatures, }; - actionTypeRegistry.has.mockReturnValue(true); - - wrapper = mountWithIntl( - - - - ); + wrapper = mountWithIntl(); // Wait for active space to resolve before requesting the component to update await act(async () => { @@ -95,7 +71,9 @@ describe('actions_connectors_list component empty', () => { it('renders empty prompt', async () => { await setup(); - expect(wrapper.find('EuiEmptyPrompt')).toHaveLength(1); + expect( + wrapper.find('[data-test-subj="createFirstConnectorEmptyPrompt"]').find('EuiEmptyPrompt') + ).toHaveLength(1); expect( wrapper.find('[data-test-subj="createFirstActionButton"]').find('EuiButton') ).toHaveLength(1); @@ -112,9 +90,6 @@ describe('actions_connectors_list component with items', () => { let wrapper: ReactWrapper; async function setup(actionConnectors?: ActionConnector[]) { - const { loadAllActions, loadActionTypes } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAllActions.mockResolvedValueOnce( actionConnectors ?? [ { @@ -156,50 +131,24 @@ describe('actions_connectors_list component with items', () => { }, ]); - const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, - application: { capabilities, navigateToApp }, + application: { capabilities }, }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities: { - ...capabilities, - actions: { - show: true, - save: true, - delete: true, - }, + ] = await mocks.getStartServices(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, }, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry: { - get() { - return null; - }, - } as any, - alertTypeRegistry: {} as any, - kibanaFeatures, }; - - wrapper = mountWithIntl( - - - - ); + wrapper = mountWithIntl(); // Wait for active space to resolve before requesting the component to update await act(async () => { @@ -263,9 +212,6 @@ describe('actions_connectors_list component empty with show only capability', () let wrapper: ReactWrapper; async function setup() { - const { loadAllActions, loadActionTypes } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAllActions.mockResolvedValueOnce([]); loadActionTypes.mockResolvedValueOnce([ { @@ -277,50 +223,24 @@ describe('actions_connectors_list component empty with show only capability', () name: 'Test2', }, ]); - const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, - application: { capabilities, navigateToApp }, + application: { capabilities }, }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities: { - ...capabilities, - actions: { - show: true, - save: false, - delete: false, - }, + ] = await mocks.getStartServices(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: false, + delete: false, }, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry: { - get() { - return null; - }, - } as any, - alertTypeRegistry: {} as any, - kibanaFeatures, }; - - wrapper = mountWithIntl( - - - - ); + wrapper = mountWithIntl(); // Wait for active space to resolve before requesting the component to update await act(async () => { @@ -340,9 +260,6 @@ describe('actions_connectors_list with show only capability', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAllActions, loadActionTypes } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAllActions.mockResolvedValueOnce([ { id: '1', @@ -369,50 +286,24 @@ describe('actions_connectors_list with show only capability', () => { name: 'Test2', }, ]); - const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, - application: { capabilities, navigateToApp }, + application: { capabilities }, }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities: { - ...capabilities, - actions: { - show: true, - save: false, - delete: false, - }, + ] = await mocks.getStartServices(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: false, + delete: false, }, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry: { - get() { - return null; - }, - } as any, - alertTypeRegistry: {} as any, - kibanaFeatures, }; - - wrapper = mountWithIntl( - - - - ); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); // Wait for active space to resolve before requesting the component to update await act(async () => { @@ -437,9 +328,6 @@ describe('actions_connectors_list component with disabled items', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAllActions, loadActionTypes } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAllActions.mockResolvedValueOnce([ { id: '1', @@ -473,50 +361,24 @@ describe('actions_connectors_list component with disabled items', () => { }, ]); - const mockes = coreMock.createSetup(); const [ { - chrome, - docLinks, - application: { capabilities, navigateToApp }, + application: { capabilities }, }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - injectedMetadata: mockes.injectedMetadata, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities: { - ...capabilities, - actions: { - show: true, - save: true, - delete: true, - }, + ] = await mocks.getStartServices(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.application.capabilities = { + ...capabilities, + actions: { + show: true, + save: true, + delete: true, }, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry: { - get() { - return null; - }, - } as any, - alertTypeRegistry: {} as any, - kibanaFeatures, }; - - wrapper = mountWithIntl( - - - - ); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); // Wait for active space to resolve before requesting the component to update await act(async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index c5d0a6aae54f..fed888b40ad8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -23,7 +23,6 @@ import { import { i18n } from '@kbn/i18n'; import { omit } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; -import { useAppDependencies } from '../../../app_context'; import { loadAllActions, loadActionTypes, deleteActions } from '../../../lib/action_connector_api'; import ConnectorAddFlyout from '../../action_connector_form/connector_add_flyout'; import ConnectorEditFlyout, { @@ -35,20 +34,19 @@ import { hasExecuteActionsCapability, } from '../../../lib/capabilities'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; -import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context'; import { checkActionTypeEnabled } from '../../../lib/check_action_type_enabled'; import './actions_connectors_list.scss'; import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types'; import { EmptyConnectorsPrompt } from '../../../components/prompts/empty_connectors_prompt'; +import { useKibana } from '../../../../common/lib/kibana'; export const ActionsConnectorsList: React.FunctionComponent = () => { const { http, - toastNotifications, - capabilities, + notifications: { toasts }, + application: { capabilities }, actionTypeRegistry, - docLinks, - } = useAppDependencies(); + } = useKibana().services; const canDelete = hasDeleteActionsCapability(capabilities); const canExecute = hasExecuteActionsCapability(capabilities); const canSave = hasSaveActionsCapability(capabilities); @@ -82,7 +80,7 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { } setActionTypesIndex(index); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionTypesMessage', { defaultMessage: 'Unable to load action types' } @@ -121,7 +119,7 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { const actionsResponse = await loadAllActions({ http }); setActions(actionsResponse); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionsMessage', { @@ -366,37 +364,30 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { setAddFlyoutVisibility(true)} /> )} {actionConnectorTableItems.length === 0 && !canSave && } - - {addFlyoutVisible ? ( - { - setAddFlyoutVisibility(false); - }} - onTestConnector={(connector) => editItem(connector, EditConectorTabs.Test)} - /> - ) : null} - {editConnectorProps.initialConnector ? ( - { - setEditConnectorProps(omit(editConnectorProps, 'initialConnector')); - }} - /> - ) : null} - + {addFlyoutVisible ? ( + { + setAddFlyoutVisibility(false); + }} + onTestConnector={(connector) => editItem(connector, EditConectorTabs.Test)} + reloadConnectors={loadActions} + actionTypeRegistry={actionTypeRegistry} + /> + ) : null} + {editConnectorProps.initialConnector ? ( + { + setEditConnectorProps(omit(editConnectorProps, 'initialConnector')); + }} + reloadConnectors={loadActions} + actionTypeRegistry={actionTypeRegistry} + /> + ) : null} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index c2a7635b4cf9..2f7a31721fa0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -7,45 +7,17 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; import { AlertDetails } from './alert_details'; -import { Alert, ActionType, ValidationResult } from '../../../../types'; +import { Alert, ActionType, AlertTypeModel } from '../../../../types'; import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiButtonEmpty, EuiText } from '@elastic/eui'; import { ViewInApp } from './view_in_app'; -import { coreMock } from 'src/core/public/mocks'; import { AlertExecutionStatusErrorReasons, ALERTS_FEATURE_ID, } from '../../../../../../alerts/common'; +import { useKibana } from '../../../../common/lib/kibana'; +import { alertTypeRegistryMock } from '../../../alert_type_registry.mock'; -const mockes = coreMock.createSetup(); - -jest.mock('../../../app_context', () => ({ - useAppDependencies: jest.fn(() => ({ - http: jest.fn(), - capabilities: { - get: jest.fn(() => ({})), - }, - actionTypeRegistry: jest.fn(), - alertTypeRegistry: { - has: jest.fn().mockReturnValue(true), - register: jest.fn(), - get: jest.fn().mockReturnValue({ - id: 'my-alert-type', - iconClass: 'test', - name: 'test-alert', - validate: (): ValidationResult => { - return { errors: {} }; - }, - requiresAppContext: false, - }), - list: jest.fn(), - }, - toastNotifications: mockes.notifications.toasts, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - uiSettings: mockes.uiSettings, - data: jest.fn(), - charts: jest.fn(), - })), -})); +jest.mock('../../../../common/lib/kibana'); jest.mock('react-router-dom', () => ({ useHistory: () => ({ @@ -61,6 +33,8 @@ jest.mock('../../../lib/capabilities', () => ({ hasSaveAlertsCapability: jest.fn(() => true), hasExecuteActionsCapability: jest.fn(() => true), })); +const useKibanaMock = useKibana as jest.Mocked; +const alertTypeRegistry = alertTypeRegistryMock.create(); const mockAlertApis = { muteAlert: jest.fn(), @@ -631,6 +605,21 @@ describe('edit button', () => { minimumLicenseRequired: 'basic', }, ]; + alertTypeRegistry.has.mockReturnValue(true); + const alertTypeR = ({ + id: 'my-alert-type', + iconClass: 'test', + name: 'test-alert', + description: 'Alert when testing', + documentationUrl: 'https://localhost.local/docs', + validate: () => { + return { errors: {} }; + }, + alertParamsExpression: () => {}, + requiresAppContext: false, + } as unknown) as AlertTypeModel; + alertTypeRegistry.get.mockReturnValue(alertTypeR); + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; it('should render an edit button when alert and actions are editable', () => { const alert = mockAlert({ @@ -714,7 +703,7 @@ describe('edit button', () => { ).toBeFalsy(); }); - it('should render an edit button when alert editable but actions arent when there are no actions on the alert', () => { + it('should render an edit button when alert editable but actions arent when there are no actions on the alert', async () => { const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); hasExecuteActionsCapability.mockReturnValue(false); const alert = mockAlert({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index d7de7e0a82c1..03734779886b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -26,7 +26,6 @@ import { EuiButton, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { useAppDependencies } from '../../../app_context'; import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; import { getAlertingSectionBreadcrumb, getAlertDetailsBreadcrumb } from '../../../lib/breadcrumb'; import { getCurrentDocTitle } from '../../../lib/doc_title'; @@ -41,6 +40,7 @@ import { AlertEdit } from '../../alert_form'; import { AlertsContextProvider } from '../../../context/alerts_context'; import { routeToAlertDetails } from '../../../constants'; import { alertsErrorReasonTranslationsMapping } from '../../alerts_list/translations'; +import { useKibana } from '../../../../common/lib/kibana'; import { alertReducer } from '../../alert_form/alert_reducer'; type AlertDetailsProps = { @@ -63,8 +63,8 @@ export const AlertDetails: React.FunctionComponent = ({ const history = useHistory(); const { http, - toastNotifications, - capabilities, + notifications: { toasts }, + application: { capabilities }, alertTypeRegistry, actionTypeRegistry, uiSettings, @@ -73,7 +73,7 @@ export const AlertDetails: React.FunctionComponent = ({ data, setBreadcrumbs, chrome, - } = useAppDependencies(); + } = useKibana().services; const [{}, dispatch] = useReducer(alertReducer, { alert }); const setInitialAlert = (value: Alert) => { dispatch({ command: { type: 'setAlert' }, payload: { key: 'alert', value } }); @@ -158,7 +158,7 @@ export const AlertDetails: React.FunctionComponent = ({ http, actionTypeRegistry, alertTypeRegistry, - toastNotifications, + toastNotifications: toasts, uiSettings, docLinks, charts, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx index 5ed924c37fe7..43ece9fc10c3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx @@ -11,13 +11,8 @@ import { ToastsApi } from 'kibana/public'; import { AlertDetailsRoute, getAlertData } from './alert_details_route'; import { Alert } from '../../../../types'; import { EuiLoadingSpinner } from '@elastic/eui'; +jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../app_context', () => { - const toastNotifications = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ toastNotifications })), - }; -}); describe('alert_details_route', () => { it('render a loader while fetching data', () => { const alert = mockAlert(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx index b10152b4364c..fc3e05fbfaed 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx @@ -10,7 +10,6 @@ import { RouteComponentProps } from 'react-router-dom'; import { EuiLoadingSpinner } from '@elastic/eui'; import { ToastsApi } from 'kibana/public'; import { Alert, AlertType, ActionType } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; import { AlertDetailsWithApi as AlertDetails } from './alert_details'; import { throwIfAbsent, throwIfIsntContained } from '../../../lib/value_validators'; import { @@ -21,6 +20,7 @@ import { ComponentOpts as ActionApis, withActionOperations, } from '../../common/components/with_actions_api_operations'; +import { useKibana } from '../../../../common/lib/kibana'; type AlertDetailsRouteProps = RouteComponentProps<{ alertId: string; @@ -36,7 +36,10 @@ export const AlertDetailsRoute: React.FunctionComponent loadAlertTypes, loadActionTypes, }) => { - const { http, toastNotifications } = useAppDependencies(); + const { + http, + notifications: { toasts }, + } = useKibana().services; const [alert, setAlert] = useState(null); const [alertType, setAlertType] = useState(null); @@ -51,9 +54,9 @@ export const AlertDetailsRoute: React.FunctionComponent setAlert, setAlertType, setActionTypes, - toastNotifications + toasts ); - }, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toastNotifications, refreshToken]); + }, [alertId, http, loadActionTypes, loadAlert, loadAlertTypes, toasts, refreshToken]); return alert && alertType && actionTypes ? ( >, setAlertType: React.Dispatch>, setActionTypes: React.Dispatch>, - toastNotifications: Pick + toasts: Pick ) { try { const loadedAlert = await loadAlert(alertId); @@ -104,7 +107,7 @@ export async function getAlertData( setAlertType(loadedAlertType); setActionTypes(loadedActionTypes); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertMessage', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx index 25bbe977fd76..52a85e8bc57b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx @@ -9,6 +9,7 @@ import { shallow } from 'enzyme'; import { AlertInstances, AlertInstanceListItem, alertInstanceToListItem } from './alert_instances'; import { Alert, AlertInstanceSummary, AlertInstanceStatus, AlertType } from '../../../../types'; import { EuiBasicTable } from '@elastic/eui'; +jest.mock('../../../../common/lib/kibana'); const fakeNow = new Date('2020-02-09T23:15:41.941Z'); const fake2MinutesAgo = new Date('2020-02-09T23:13:41.941Z'); @@ -24,13 +25,6 @@ beforeAll(() => { global.Date.now = jest.fn(() => fakeNow.getTime()); }); -jest.mock('../../../app_context', () => { - const toastNotifications = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ toastNotifications })), - }; -}); - describe('alert_instances', () => { it('render a list of alert instances', () => { const alert = mockAlert(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx index 3a171d469d4a..2256efe30831 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx @@ -10,16 +10,11 @@ import { ToastsApi } from 'kibana/public'; import { AlertInstancesRoute, getAlertInstanceSummary } from './alert_instances_route'; import { Alert, AlertInstanceSummary, AlertType } from '../../../../types'; import { EuiLoadingSpinner } from '@elastic/eui'; +jest.mock('../../../../common/lib/kibana'); const fakeNow = new Date('2020-02-09T23:15:41.941Z'); const fake2MinutesAgo = new Date('2020-02-09T23:13:41.941Z'); -jest.mock('../../../app_context', () => { - const toastNotifications = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ toastNotifications })), - }; -}); describe('alert_instance_summary_route', () => { it('render a loader while fetching data', () => { const alert = mockAlert(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx index 83a09e9eafcc..e1e0866d886a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx @@ -9,12 +9,12 @@ import { ToastsApi } from 'kibana/public'; import React, { useState, useEffect } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; import { Alert, AlertInstanceSummary, AlertType } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; import { ComponentOpts as AlertApis, withBulkAlertOperations, } from '../../common/components/with_bulk_alert_api_operations'; import { AlertInstancesWithApi as AlertInstances } from './alert_instances'; +import { useKibana } from '../../../../common/lib/kibana'; type WithAlertInstanceSummaryProps = { alert: Alert; @@ -30,19 +30,16 @@ export const AlertInstancesRoute: React.FunctionComponent { - const { toastNotifications } = useAppDependencies(); + const { + notifications: { toasts }, + } = useKibana().services; const [alertInstanceSummary, setAlertInstanceSummary] = useState( null ); useEffect(() => { - getAlertInstanceSummary( - alert.id, - loadAlertInstanceSummary, - setAlertInstanceSummary, - toastNotifications - ); + getAlertInstanceSummary(alert.id, loadAlertInstanceSummary, setAlertInstanceSummary, toasts); // eslint-disable-next-line react-hooks/exhaustive-deps }, [alert]); @@ -70,13 +67,13 @@ export async function getAlertInstanceSummary( alertId: string, loadAlertInstanceSummary: AlertApis['loadAlertInstanceSummary'], setAlertInstanceSummary: React.Dispatch>, - toastNotifications: Pick + toasts: Pick ) { try { const loadedInstanceSummary = await loadAlertInstanceSummary(alertId); setAlertInstanceSummary(loadedInstanceSummary); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertInstanceSummaryMessage', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx index 7e43fd22ff8c..d026c43b8496 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx @@ -10,28 +10,8 @@ import { act } from 'react-dom/test-utils'; import { Alert } from '../../../../types'; import { ViewInApp } from './view_in_app'; -import { useAppDependencies } from '../../../app_context'; - -jest.mock('../../../app_context', () => { - const alerts = { - getNavigation: jest.fn(async (id) => - id === 'alert-with-nav' ? { path: '/alert' } : undefined - ), - }; - const navigateToApp = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ - http: jest.fn(), - navigateToApp, - alerts, - legacy: { - capabilities: { - get: jest.fn(() => ({})), - }, - }, - })), - }; -}); +import { useKibana } from '../../../../common/lib/kibana'; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../lib/capabilities', () => ({ hasSaveAlertsCapability: jest.fn(() => true), @@ -41,8 +21,7 @@ describe('view in app', () => { describe('link to the app that created the alert', () => { it('is disabled when there is no navigation', async () => { const alert = mockAlert(); - const { alerts } = useAppDependencies(); - + const { alerts } = useKibana().services; let component: ReactWrapper; await act(async () => { // use mount as we need useEffect to run @@ -59,7 +38,9 @@ describe('view in app', () => { it('enabled when there is navigation', async () => { const alert = mockAlert({ id: 'alert-with-nav', consumer: 'siem' }); - const { navigateToApp } = useAppDependencies(); + const { + application: { navigateToApp }, + } = useKibana().services; let component: ReactWrapper; act(async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx index 5b5de070a94e..865958ea2856 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.tsx @@ -10,7 +10,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { CoreStart } from 'kibana/public'; import { fromNullable, fold } from 'fp-ts/lib/Option'; import { pipe } from 'fp-ts/lib/pipeable'; -import { useAppDependencies } from '../../../app_context'; import { AlertNavigation, @@ -18,6 +17,7 @@ import { AlertUrlNavigation, } from '../../../../../../alerts/common'; import { Alert } from '../../../../types'; +import { useKibana } from '../../../../common/lib/kibana'; export interface ViewInAppProps { alert: Alert; @@ -28,7 +28,10 @@ const NO_NAVIGATION = false; type AlertNavigationLoadingState = AlertNavigation | false | null; export const ViewInApp: React.FunctionComponent = ({ alert }) => { - const { navigateToApp, alerts: maybeAlerting } = useAppDependencies(); + const { + application: { navigateToApp }, + alerts: maybeAlerting, + } = useKibana().services; const [alertNavigation, setAlertNavigation] = useState(null); useEffect(() => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index a69ee7102c13..084da8905663 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -17,8 +17,9 @@ import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { ReactWrapper } from 'enzyme'; -import { AppContextProvider } from '../../app_context'; import { ALERTS_FEATURE_ID } from '../../../../../alerts/common'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; + jest.mock('../../lib/alert_api', () => ({ loadAlertTypes: jest.fn(), health: jest.fn((async) => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: true })), @@ -130,7 +131,7 @@ describe('alert_add', () => { actionTypeRegistry.has.mockReturnValue(true); wrapper = mountWithIntl( - + { @@ -160,7 +161,7 @@ describe('alert_add', () => { initialValues={initialValues} /> - + ); // Wait for active space to resolve before requesting the component to update diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index 77941d5c2bca..11a35b313ef1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -13,7 +13,7 @@ import { AlertsContextProvider } from '../../context/alerts_context'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { ReactWrapper } from 'enzyme'; import AlertEdit from './alert_edit'; -import { AppContextProvider } from '../../app_context'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -122,7 +122,7 @@ describe('alert_edit', () => { actionTypeRegistry.has.mockReturnValue(true); wrapper = mountWithIntl( - + { @@ -139,7 +139,7 @@ describe('alert_edit', () => { > {}} initialAlert={alert} /> - + ); // Wait for active space to resolve before requesting the component to update await act(async () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 7fd5bdc8d870..a950af9c99a5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -496,12 +496,8 @@ export const AlertForm = ({ } setAlertProperty={setActions} setActionParamsProperty={setActionParamsProperty} - http={http} actionTypeRegistry={actionTypeRegistry} defaultActionMessage={alertTypeModel?.defaultActionMessage} - toastNotifications={toastNotifications} - docLinks={docLinks} - capabilities={capabilities} /> ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index a29c112b536f..351eccf2934b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -6,22 +6,18 @@ import * as React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test/jest'; -import { coreMock, scopedHistoryMock } from '../../../../../../../../src/core/public/mocks'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; import { alertTypeRegistryMock } from '../../../alert_type_registry.mock'; import { AlertsList } from './alerts_list'; import { ValidationResult } from '../../../../types'; -import { AppContextProvider } from '../../../app_context'; -import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks'; -import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; -import { alertingPluginMock } from '../../../../../../alerts/public/mocks'; import { AlertExecutionStatusErrorReasons, ALERTS_FEATURE_ID, } from '../../../../../../alerts/common'; -import { featuresPluginMock } from '../../../../../../features/public/mocks'; +import { useKibana } from '../../../../common/lib/kibana'; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../lib/action_connector_api', () => ({ loadActionTypes: jest.fn(), @@ -39,6 +35,8 @@ jest.mock('react-router-dom', () => ({ pathname: '/triggersActions/alerts/', }), })); +const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); +const { loadActionTypes, loadAllActions } = jest.requireMock('../../../lib/action_connector_api'); const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -67,14 +65,11 @@ const alertTypeFromApi = { }; alertTypeRegistry.list.mockReturnValue([alertType]); actionTypeRegistry.list.mockReturnValue([]); +const useKibanaMock = useKibana as jest.Mocked; describe('alerts_list component empty', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); - const { loadActionTypes, loadAllActions } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, @@ -94,40 +89,13 @@ describe('alerts_list component empty', () => { loadAlertTypes.mockResolvedValue([alertTypeFromApi]); loadAllActions.mockResolvedValue([]); - const mockes = coreMock.createSetup(); - const [ - { - chrome, - docLinks, - application: { capabilities, navigateToApp }, - }, - ] = await mockes.getStartServices(); - - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry, - alertTypeRegistry, - kibanaFeatures, - }; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; - wrapper = mountWithIntl( - - - - ); + wrapper = mountWithIntl(); await act(async () => { await nextTick(); @@ -153,10 +121,6 @@ describe('alerts_list component with items', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); - const { loadActionTypes, loadAllActions } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, @@ -267,40 +231,14 @@ describe('alerts_list component with items', () => { ]); loadAlertTypes.mockResolvedValue([alertTypeFromApi]); loadAllActions.mockResolvedValue([]); - const mockes = coreMock.createSetup(); - const [ - { - chrome, - docLinks, - application: { capabilities, navigateToApp }, - }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry, - alertTypeRegistry, - kibanaFeatures, - }; alertTypeRegistry.has.mockReturnValue(true); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; - wrapper = mountWithIntl( - - - - ); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); await act(async () => { await nextTick(); @@ -328,10 +266,6 @@ describe('alerts_list component empty with show only capability', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); - const { loadActionTypes, loadAllActions } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, @@ -350,42 +284,12 @@ describe('alerts_list component empty with show only capability', () => { ]); loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); loadAllActions.mockResolvedValue([]); - const mockes = coreMock.createSetup(); - const [ - { - chrome, - docLinks, - application: { capabilities, navigateToApp }, - }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry: { - get() { - return null; - }, - } as any, - alertTypeRegistry: {} as any, - kibanaFeatures, - }; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; - wrapper = mountWithIntl( - - - - ); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); await act(async () => { await nextTick(); @@ -403,10 +307,6 @@ describe('alerts_list with show only capability', () => { let wrapper: ReactWrapper; async function setup() { - const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); - const { loadActionTypes, loadAllActions } = jest.requireMock( - '../../../lib/action_connector_api' - ); loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, @@ -471,40 +371,14 @@ describe('alerts_list with show only capability', () => { loadAlertTypes.mockResolvedValue([alertTypeFromApi]); loadAllActions.mockResolvedValue([]); - const mockes = coreMock.createSetup(); - const [ - { - chrome, - docLinks, - application: { capabilities, navigateToApp }, - }, - ] = await mockes.getStartServices(); - const kibanaFeatures = await featuresPluginMock.createStart().getFeatures(); - const deps = { - chrome, - docLinks, - data: dataPluginMock.createStartContract(), - charts: chartPluginMock.createStartContract(), - alerting: alertingPluginMock.createStartContract(), - toastNotifications: mockes.notifications.toasts, - http: mockes.http, - uiSettings: mockes.uiSettings, - navigateToApp, - capabilities, - history: scopedHistoryMock.create(), - setBreadcrumbs: jest.fn(), - actionTypeRegistry, - alertTypeRegistry, - kibanaFeatures, - }; alertTypeRegistry.has.mockReturnValue(false); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; - wrapper = mountWithIntl( - - - - ); + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.actionTypeRegistry = actionTypeRegistry; + wrapper = mountWithIntl(); await act(async () => { await nextTick(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 11d6f3470fec..0a674b4d5486 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -31,7 +31,6 @@ import { useHistory } from 'react-router-dom'; import { isEmpty } from 'lodash'; import { AlertsContextProvider } from '../../../context/alerts_context'; -import { useAppDependencies } from '../../../app_context'; import { ActionType, Alert, AlertTableItem, AlertTypeIndex, Pagination } from '../../../../types'; import { AlertAdd } from '../../alert_form'; import { BulkOperationPopover } from '../../common/components/bulk_operation_popover'; @@ -58,6 +57,7 @@ import { } from '../../../../../../alerts/common'; import { hasAllPrivilege } from '../../../lib/capabilities'; import { alertsStatusesTranslationsMapping } from '../translations'; +import { useKibana } from '../../../../common/lib/kibana'; const ENTER_KEY = 13; @@ -76,8 +76,8 @@ export const AlertsList: React.FunctionComponent = () => { const history = useHistory(); const { http, - toastNotifications, - capabilities, + notifications: { toasts }, + application: { capabilities }, alertTypeRegistry, actionTypeRegistry, uiSettings, @@ -85,7 +85,7 @@ export const AlertsList: React.FunctionComponent = () => { charts, data, kibanaFeatures, - } = useAppDependencies(); + } = useKibana().services; const canExecuteActions = hasExecuteActionsCapability(capabilities); const [actionTypes, setActionTypes] = useState([]); @@ -143,7 +143,7 @@ export const AlertsList: React.FunctionComponent = () => { } setAlertTypesState({ isLoading: false, data: index, isInitialized: true }); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertTypesMessage', { defaultMessage: 'Unable to load alert types' } @@ -160,7 +160,7 @@ export const AlertsList: React.FunctionComponent = () => { const result = await loadActionTypes({ http }); setActionTypes(result.filter((actionType) => actionTypeRegistry.has(actionType.id))); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.unableToLoadActionTypesMessage', { defaultMessage: 'Unable to load action types' } @@ -194,7 +194,7 @@ export const AlertsList: React.FunctionComponent = () => { setPage({ ...page, index: 0 }); } } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsMessage', { @@ -220,7 +220,7 @@ export const AlertsList: React.FunctionComponent = () => { setAlertsStatusesTotal(alertsAggs.alertExecutionStatus); } } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsStatusesInfoMessage', { @@ -664,7 +664,7 @@ export const AlertsList: React.FunctionComponent = () => { http, actionTypeRegistry, alertTypeRegistry, - toastNotifications, + toastNotifications: toasts, uiSettings, docLinks, charts, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx index 26bc8b869a06..f87768c8d453 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/alert_quick_edit_buttons.tsx @@ -10,12 +10,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { Alert } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; import { withBulkAlertOperations, ComponentOpts as BulkOperationsComponentOpts, } from './with_bulk_alert_api_operations'; import './alert_quick_edit_buttons.scss'; +import { useKibana } from '../../../../common/lib/kibana'; export type ComponentOpts = { selectedItems: Alert[]; @@ -34,7 +34,9 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ disableAlerts, setAlertsToDelete, }: ComponentOpts) => { - const { toastNotifications } = useAppDependencies(); + const { + notifications: { toasts }, + } = useKibana().services; const [isMutingAlerts, setIsMutingAlerts] = useState(false); const [isUnmutingAlerts, setIsUnmutingAlerts] = useState(false); @@ -53,7 +55,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ try { await muteAlerts(selectedItems); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToMuteAlertsMessage', { @@ -73,7 +75,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ try { await unmuteAlerts(selectedItems); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToUnmuteAlertsMessage', { @@ -93,7 +95,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ try { await enableAlerts(selectedItems); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToEnableAlertsMessage', { @@ -113,7 +115,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ try { await disableAlerts(selectedItems); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToDisableAlertsMessage', { @@ -133,7 +135,7 @@ export const AlertQuickEditButtons: React.FunctionComponent = ({ try { setAlertsToDelete(selectedItems.map((selected: any) => selected.id)); } catch (e) { - toastNotifications.addDanger({ + toasts.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToDeleteAlertsMessage', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx index dd6b8775ba3d..09a7fded9769 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx @@ -7,19 +7,12 @@ import * as React from 'react'; import { shallow, mount } from 'enzyme'; import { withActionOperations, ComponentOpts } from './with_actions_api_operations'; import * as actionApis from '../../../lib/action_connector_api'; -import { useAppDependencies } from '../../../app_context'; +import { useKibana } from '../../../../common/lib/kibana'; +const useKibanaMock = useKibana as jest.Mocked; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../lib/action_connector_api'); -jest.mock('../../../app_context', () => { - const http = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ - http, - })), - }; -}); - describe('with_action_api_operations', () => { beforeEach(() => { jest.clearAllMocks(); @@ -36,7 +29,7 @@ describe('with_action_api_operations', () => { }); it('loadActionTypes calls the loadActionTypes api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ loadActionTypes }: ComponentOpts) => { return ; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx index 45e6c6b10532..ff66d31044b0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx @@ -7,8 +7,8 @@ import React from 'react'; import { ActionType } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; import { loadActionTypes } from '../../../lib/action_connector_api'; +import { useKibana } from '../../../../common/lib/kibana'; export interface ComponentOpts { loadActionTypes: () => Promise; @@ -20,7 +20,10 @@ export function withActionOperations( WrappedComponent: React.ComponentType ): React.FunctionComponent> { return (props: PropsWithOptionalApiHandlers) => { - const { http } = useAppDependencies(); + const { http } = useKibana().services; + if (!http) { + throw new Error('KibanaContext has not been initalized correctly.'); + } return ( loadActionTypes({ http })} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx index 72d4f8857a61..47ef744f5d95 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx @@ -8,24 +8,12 @@ import { shallow, mount } from 'enzyme'; import uuid from 'uuid'; import { withBulkAlertOperations, ComponentOpts } from './with_bulk_alert_api_operations'; import * as alertApi from '../../../lib/alert_api'; -import { useAppDependencies } from '../../../app_context'; import { Alert } from '../../../../types'; +import { useKibana } from '../../../../common/lib/kibana'; +jest.mock('../../../../common/lib/kibana'); jest.mock('../../../lib/alert_api'); - -jest.mock('../../../app_context', () => { - const http = jest.fn(); - return { - useAppDependencies: jest.fn(() => ({ - http, - legacy: { - capabilities: { - get: jest.fn(() => ({})), - }, - }, - })), - }; -}); +const useKibanaMock = useKibana as jest.Mocked; describe('with_bulk_alert_api_operations', () => { beforeEach(() => { @@ -55,7 +43,7 @@ describe('with_bulk_alert_api_operations', () => { // single alert it('muteAlert calls the muteAlert api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ muteAlert, alert }: ComponentOpts & { alert: Alert }) => { return ; }; @@ -70,7 +58,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('unmuteAlert calls the unmuteAlert api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ unmuteAlert, alert }: ComponentOpts & { alert: Alert }) => { return ; }; @@ -85,7 +73,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('enableAlert calls the muteAlerts api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ enableAlert, alert }: ComponentOpts & { alert: Alert }) => { return ; }; @@ -100,7 +88,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('disableAlert calls the disableAlert api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ disableAlert, alert }: ComponentOpts & { alert: Alert }) => { return ; }; @@ -115,7 +103,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('deleteAlert calls the deleteAlert api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ deleteAlert, alert }: ComponentOpts & { alert: Alert }) => { return ; }; @@ -131,7 +119,7 @@ describe('with_bulk_alert_api_operations', () => { // bulk alerts it('muteAlerts calls the muteAlerts api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ muteAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { return ; }; @@ -146,7 +134,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('unmuteAlerts calls the unmuteAlerts api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ unmuteAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { return ; }; @@ -161,7 +149,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('enableAlerts calls the muteAlertss api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ enableAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { return ; }; @@ -180,7 +168,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('disableAlerts calls the disableAlerts api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ disableAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { return ; }; @@ -198,7 +186,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('deleteAlerts calls the deleteAlerts api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ deleteAlerts, alerts }: ComponentOpts & { alerts: Alert[] }) => { return ; }; @@ -213,7 +201,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('loadAlert calls the loadAlert api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ loadAlert, alertId, @@ -231,7 +219,7 @@ describe('with_bulk_alert_api_operations', () => { }); it('loadAlertTypes calls the loadAlertTypes api', () => { - const { http } = useAppDependencies(); + const { http } = useKibanaMock().services; const ComponentToExtend = ({ loadAlertTypes }: ComponentOpts) => { return ; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index dc961482f182..77f7631b6d63 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -13,7 +13,6 @@ import { AlertInstanceSummary, AlertingFrameworkHealth, } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; import { deleteAlerts, disableAlerts, @@ -32,6 +31,7 @@ import { loadAlertTypes, health, } from '../../../lib/alert_api'; +import { useKibana } from '../../../../common/lib/kibana'; export interface ComponentOpts { muteAlerts: (alerts: Alert[]) => Promise; @@ -69,7 +69,10 @@ export function withBulkAlertOperations( WrappedComponent: React.ComponentType ): React.FunctionComponent> { return (props: PropsWithOptionalApiHandlers) => { - const { http } = useAppDependencies(); + const { http } = useKibana().services; + if (!http) { + throw new Error('KibanaContext has not been initalized correctly.'); + } return ( { + const ConnectorAddFlyoutLazy = lazy( + () => import('../application/sections/action_connector_form/connector_add_flyout') + ); + return ( + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_edit_connector_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_edit_connector_flyout.tsx new file mode 100644 index 000000000000..a95e417da7f5 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_edit_connector_flyout.tsx @@ -0,0 +1,18 @@ +/* + * 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, { lazy, Suspense } from 'react'; +import type { ConnectorEditFlyoutProps } from '../application/sections/action_connector_form/connector_edit_flyout'; + +export const getEditConnectorFlyoutLazy = (props: ConnectorEditFlyoutProps) => { + const ConnectorEditFlyoutLazy = lazy( + () => import('../application/sections/action_connector_form/connector_edit_flyout') + ); + return ( + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/__mocks__/index.ts new file mode 100644 index 000000000000..0ec5f0e59301 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/__mocks__/index.ts @@ -0,0 +1,32 @@ +/* + * 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 { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; +import { notificationServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { + createKibanaContextProviderMock, + createStartServicesMock, + createWithKibanaMock, +} from '../kibana_react.mock'; +const mockStartServicesMock = createStartServicesMock(); +export const KibanaServices = { get: jest.fn(), getKibanaVersion: jest.fn(() => '8.0.0') }; +export const useKibana = jest.fn().mockReturnValue({ + services: { + ...mockStartServicesMock, + data: dataPluginMock.createStartContract(), + }, +}); +export const useHttp = jest.fn().mockReturnValue(createStartServicesMock().http); +export const useTimeZone = jest.fn(); +export const useDateFormat = jest.fn(); +export const useBasePath = jest.fn(() => '/test/base/path'); +export const useToasts = jest + .fn() + .mockReturnValue(notificationServiceMock.createStartContract().toasts); +export const useCurrentUser = jest.fn(); +export const withKibana = jest.fn(createWithKibanaMock()); +export const KibanaContextProvider = jest.fn(createKibanaContextProviderMock()); +export const useGetUserSavedObjectPermissions = jest.fn(); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/index.ts new file mode 100644 index 000000000000..b9cb71d4adb4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/index.ts @@ -0,0 +1,7 @@ +/* + * 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 * from './kibana_react'; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts new file mode 100644 index 000000000000..ce1d9887bbb2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts @@ -0,0 +1,64 @@ +/* + * 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 { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; + +import { coreMock, scopedHistoryMock } from '../../../../../../../src/core/public/mocks'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { TriggersAndActionsUiServices } from '../../../application/app'; +import { AlertTypeRegistryContract, ActionTypeRegistryContract } from '../../../types'; + +export const createStartServicesMock = (): TriggersAndActionsUiServices => { + const core = coreMock.createStart(); + return { + ...core, + alertTypeRegistry: { + has: jest.fn(), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + } as AlertTypeRegistryContract, + notifications: core.notifications, + dataPlugin: jest.fn(), + navigateToApp: jest.fn(), + alerts: { + getNavigation: jest.fn(async (id) => + id === 'alert-with-nav' ? { path: '/alert' } : undefined + ), + }, + history: scopedHistoryMock.create(), + setBreadcrumbs: jest.fn(), + data: dataPluginMock.createStartContract(), + actionTypeRegistry: { + has: jest.fn(), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + } as ActionTypeRegistryContract, + charts: chartPluginMock.createStartContract(), + kibanaFeatures: [], + element: ({ + style: { cursor: 'pointer' }, + } as unknown) as HTMLElement, + } as TriggersAndActionsUiServices; +}; + +export const createWithKibanaMock = () => { + const services = createStartServicesMock(); + + return (Component: unknown) => (props: unknown) => { + return React.createElement(Component as string, { ...(props as object), kibana: { services } }); + }; +}; + +export const createKibanaContextProviderMock = () => { + const services = createStartServicesMock(); + + return ({ children }: { children: React.ReactNode }) => + React.createElement(KibanaContextProvider, { services }, children); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.ts new file mode 100644 index 000000000000..483432251cee --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.ts @@ -0,0 +1,30 @@ +/* + * 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 { + KibanaContextProvider, + KibanaReactContextValue, + useKibana, + useUiSetting, + useUiSetting$, + withKibana, +} from '../../../../../../../src/plugins/kibana_react/public'; +import { TriggersAndActionsUiServices } from '../../../application/app'; + +export type KibanaContext = KibanaReactContextValue; +export interface WithKibanaProps { + kibana: KibanaContext; +} + +const useTypedKibana = () => useKibana(); + +export { + KibanaContextProvider, + useTypedKibana as useKibana, + useUiSetting, + useUiSetting$, + withKibana, +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 025741aa7f9b..6955a9432614 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -7,7 +7,6 @@ import { Plugin } from './plugin'; export { AlertsContextProvider, AlertsContextValue } from './application/context/alerts_context'; -export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context'; export { AlertAdd } from './application/sections/alert_form'; export { AlertEdit, @@ -47,3 +46,4 @@ export * from './plugin'; export { TIME_UNITS } from './application/constants'; export { getTimeUnitLabel } from './common/lib/get_time_unit_label'; export { ForLastExpression } from './common/expression_items/for_the_last'; +export { TriggersAndActionsUiServices } from '../public/application/app'; diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index a30747afe691..365eac9c031b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -7,6 +7,7 @@ import { CoreSetup, CoreStart, Plugin as CorePlugin } from 'src/core/public'; import { i18n } from '@kbn/i18n'; +import { ReactElement } from 'react'; import { FeaturesPluginStart } from '../../features/public'; import { registerBuiltInActionTypes } from './application/components/builtin_action_types'; import { ActionTypeModel, AlertTypeModel } from './types'; @@ -23,6 +24,10 @@ import { ChartsPluginStart } from '../../../../src/plugins/charts/public'; import { PluginStartContract as AlertingStart } from '../../alerts/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; +import type { ConnectorAddFlyoutProps } from './application/sections/action_connector_form/connector_add_flyout'; +import type { ConnectorEditFlyoutProps } from './application/sections/action_connector_form/connector_edit_flyout'; +import { getAddConnectorFlyoutLazy } from './common/get_add_connector_flyout'; +import { getEditConnectorFlyoutLazy } from './common/get_edit_connector_flyout'; export interface TriggersAndActionsUIPublicPluginSetup { actionTypeRegistry: TypeRegistry; @@ -32,6 +37,12 @@ export interface TriggersAndActionsUIPublicPluginSetup { export interface TriggersAndActionsUIPublicPluginStart { actionTypeRegistry: TypeRegistry; alertTypeRegistry: TypeRegistry; + getAddConnectorFlyout: ( + props: Omit + ) => ReactElement | null; + getEditConnectorFlyout: ( + props: Omit + ) => ReactElement | null; } interface PluginsSetup { @@ -100,23 +111,15 @@ export class Plugin unknown ]; - const { boot } = await import('./application/boot'); + const { renderApp } = await import('./application/app'); const kibanaFeatures = await pluginsStart.features.getFeatures(); - return boot({ + return renderApp({ + ...coreStart, data: pluginsStart.data, charts: pluginsStart.charts, alerts: pluginsStart.alerts, element: params.element, - toastNotifications: coreStart.notifications.toasts, storage: new Storage(window.localStorage), - http: coreStart.http, - uiSettings: coreStart.uiSettings, - docLinks: coreStart.docLinks, - chrome: coreStart.chrome, - savedObjects: coreStart.savedObjects, - I18nContext: coreStart.i18n.Context, - capabilities: coreStart.application.capabilities, - navigateToApp: coreStart.application.navigateToApp, setBreadcrumbs: params.setBreadcrumbs, history: params.history, actionTypeRegistry, @@ -140,6 +143,15 @@ export class Plugin return { actionTypeRegistry: this.actionTypeRegistry, alertTypeRegistry: this.alertTypeRegistry, + getAddConnectorFlyout: (props: Omit) => { + return getAddConnectorFlyoutLazy({ ...props, actionTypeRegistry: this.actionTypeRegistry }); + }, + getEditConnectorFlyout: (props: Omit) => { + return getEditConnectorFlyoutLazy({ + ...props, + actionTypeRegistry: this.actionTypeRegistry, + }); + }, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index cc0522eeb52a..be8b7b9757e9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { HttpSetup, DocLinksStart, ToastsSetup } from 'kibana/public'; +import type { DocLinksStart } from 'kibana/public'; import { ComponentType } from 'react'; import { ActionGroup, AlertActionParam } from '../../alerts/common'; import { ActionType } from '../../actions/common'; @@ -43,8 +43,6 @@ export interface ActionConnectorFieldsProps { editActionConfig: (property: string, value: any) => void; editActionSecrets: (property: string, value: any) => void; errors: IErrorObject; - docLinks: DocLinksStart; - http?: HttpSetup; readOnly: boolean; consumer?: string; } @@ -56,9 +54,6 @@ export interface ActionParamsProps { errors: IErrorObject; messageVariables?: ActionVariable[]; defaultMessage?: string; - docLinks: DocLinksStart; - http: HttpSetup; - toastNotifications: ToastsSetup; actionConnector?: ActionConnector; } diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index a5b8bc859ad9..c928ac0dc458 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -99,7 +99,9 @@ const Application = (props: UptimeAppProps) => { - + diff --git a/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx b/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx index 9baacaf21acd..33a186bfe626 100644 --- a/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx +++ b/x-pack/plugins/uptime/public/components/settings/add_connector_flyout.tsx @@ -4,40 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { useDispatch } from 'react-redux'; import { EuiButtonEmpty } from '@elastic/eui'; -import { HttpStart, DocLinksStart, NotificationsStart, ApplicationStart } from 'src/core/public'; -import { - ActionsConnectorsContextProvider, - ConnectorAddFlyout, - TriggersAndActionsUIPublicPluginStart, -} from '../../../../triggers_actions_ui/public'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public'; import { getConnectorsAction } from '../../state/alerts/alerts'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; interface Props { focusInput: () => void; } + interface KibanaDeps { triggersActionsUi: TriggersAndActionsUIPublicPluginStart; - application: ApplicationStart; - docLinks: DocLinksStart; - http: HttpStart; - notifications: NotificationsStart; } export const AddConnectorFlyout = ({ focusInput }: Props) => { const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); - const { services: { - triggersActionsUi: { actionTypeRegistry }, - application, - docLinks, - http, - notifications, + triggersActionsUi: { getAddConnectorFlyout }, }, } = useKibana(); @@ -48,6 +35,16 @@ export const AddConnectorFlyout = ({ focusInput }: Props) => { focusInput(); }, [addFlyoutVisible, dispatch, focusInput]); + const ConnectorAddFlyout = useMemo( + () => + getAddConnectorFlyout({ + consumer: 'uptime', + onClose: () => setAddFlyoutVisibility(false), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + return ( <> { defaultMessage="Create connector" /> - - {addFlyoutVisible ? ( - setAddFlyoutVisibility(false)} /> - ) : null} - + {addFlyoutVisible ? ConnectorAddFlyout : null} ); }; From ab8a2f7427ffbd79a2bcc2e8416972f2347637f6 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 24 Nov 2020 11:46:19 +0300 Subject: [PATCH 06/89] [docs] Convert migration guide to asciidoc (#82600) * Initial conversion to asciidoc * Update and split migration guide * Convert MIGRATION_EXAMPLES to asciidoc * build with --focus flag * convert migration guide to asciidoc * cleanup migration_examples * fix wrong Heading size * update links in docs * Apply suggestions from code review Co-authored-by: Rudolf Meijering * Apply suggestions from code review Co-authored-by: Rudolf Meijering * add tooling section * explain purpose of each lifecycle method * cleanup docs * cleanup p2 * fix wrong link * resturcture core docs * fix wrong link * update missing links * Apply suggestions from code review Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * address comments * add a commenta about plugin-helpers preconfigured * improve density of tables * fix lik * remove links to the migration guide * address comments * Apply suggestions from code review Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * address @gchaps comments * Apply suggestions from code review Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * change format of ES client change list Co-authored-by: Josh Dover Co-authored-by: Rudolf Meijering Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- .../architecture/add-data-tutorials.asciidoc | 4 +- .../architecture/core/index.asciidoc | 451 +++++ .../saved-objects-service.asciidoc} | 6 +- docs/developer/architecture/index.asciidoc | 18 +- .../kibana-platform-plugin-api.asciidoc | 347 ++++ docs/developer/best-practices/index.asciidoc | 4 + .../best-practices/performance.asciidoc | 101 + .../development-ci-metrics.asciidoc | 4 +- .../development-plugin-resources.asciidoc | 5 +- docs/developer/plugin/index.asciidoc | 34 +- ...migrating-legacy-plugins-examples.asciidoc | 1186 +++++++++++ .../plugin/migrating-legacy-plugins.asciidoc | 608 ++++++ docs/developer/plugin/plugin-tooling.asciidoc | 50 + .../plugin/testing-kibana-plugin.asciidoc | 63 + src/core/MIGRATION.md | 1774 ----------------- src/core/MIGRATION_EXAMPLES.md | 1291 ------------ src/core/README.md | 2 +- 17 files changed, 2841 insertions(+), 3107 deletions(-) create mode 100644 docs/developer/architecture/core/index.asciidoc rename docs/developer/architecture/{development-plugin-saved-objects.asciidoc => core/saved-objects-service.asciidoc} (98%) create mode 100644 docs/developer/architecture/kibana-platform-plugin-api.asciidoc create mode 100644 docs/developer/best-practices/performance.asciidoc create mode 100644 docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc create mode 100644 docs/developer/plugin/migrating-legacy-plugins.asciidoc create mode 100644 docs/developer/plugin/plugin-tooling.asciidoc create mode 100644 docs/developer/plugin/testing-kibana-plugin.asciidoc delete mode 100644 src/core/MIGRATION.md delete mode 100644 src/core/MIGRATION_EXAMPLES.md diff --git a/docs/developer/architecture/add-data-tutorials.asciidoc b/docs/developer/architecture/add-data-tutorials.asciidoc index 3891b87a00e6..8b6f7d544836 100644 --- a/docs/developer/architecture/add-data-tutorials.asciidoc +++ b/docs/developer/architecture/add-data-tutorials.asciidoc @@ -28,11 +28,11 @@ Then register the tutorial object by calling `home.tutorials.registerTutorial(tu String values can contain variables that are substituted when rendered. Variables are specified by `{}`. For example: `{config.docs.version}` is rendered as `6.2` when running the tutorial in {kib} 6.2. -link:{kib-repo}tree/{branch}/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/replace_template_strings.js#L23[Provided variables] +link:{kib-repo}tree/{branch}/src/plugins/home/public/application/components/tutorial/replace_template_strings.js[Provided variables] [discrete] ==== Markdown String values can contain limited Markdown syntax. -link:{kib-repo}tree/{branch}/src/legacy/core_plugins/kibana/public/home/components/tutorial/content.js#L8[Enabled Markdown grammars] +link:{kib-repo}tree/{branch}/src/legacy/core_plugins/kibana/public/home/components/tutorial/content.js[Enabled Markdown grammars] diff --git a/docs/developer/architecture/core/index.asciidoc b/docs/developer/architecture/core/index.asciidoc new file mode 100644 index 000000000000..48595690f978 --- /dev/null +++ b/docs/developer/architecture/core/index.asciidoc @@ -0,0 +1,451 @@ +[[kibana-platform-api]] +== {kib} Core API + +experimental[] + +{kib} Core provides a set of low-level API's required to run all {kib} plugins. +These API's are injected into your plugin's lifecycle methods and may be invoked during that lifecycle only: + +[source,typescript] +---- +import type { PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; + +export class MyPlugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup) { + // called when plugin is setting up during Kibana's startup sequence + } + + public start(core: CoreStart) { + // called after all plugins are set up + } + + public stop() { + // called when plugin is torn down during Kibana's shutdown sequence + } +} +---- + +=== Server-side +[[configuration-service]] +==== Configuration service +{kib} provides `ConfigService` if a plugin developer may want to support +adjustable runtime behavior for their plugins. +Plugins can only read their own configuration values, it is not possible to access the configuration values from {kib} Core or other plugins directly. + +[source,js] +---- +// in Legacy platform +const basePath = config.get('server.basePath'); +// in Kibana Platform 'basePath' belongs to the http service +const basePath = core.http.basePath.get(request); +---- + +To have access to your plugin config, you _should_: + +* Declare plugin-specific `configPath` (will fallback to plugin `id` +if not specified) in {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`kibana.json`] manifest file. +* Export schema validation for the config from plugin's main file. Schema is +mandatory. If a plugin reads from the config without schema declaration, +`ConfigService` will throw an error. + +*my_plugin/server/index.ts* +[source,typescript] +---- +import { schema, TypeOf } from '@kbn/config-schema'; +export const plugin = … +export const config = { + schema: schema.object(…), +}; +export type MyPluginConfigType = TypeOf; +---- + +* Read config value exposed via `PluginInitializerContext`. +*my_plugin/server/index.ts* +[source,typescript] +---- +import type { PluginInitializerContext } from 'kibana/server'; +export class MyPlugin { + constructor(initializerContext: PluginInitializerContext) { + this.config$ = initializerContext.config.create(); + // or if config is optional: + this.config$ = initializerContext.config.createIfExists(); + } +---- + +If your plugin also has a client-side part, you can also expose +configuration properties to it using the configuration `exposeToBrowser` +allow-list property. + +*my_plugin/server/index.ts* +[source,typescript] +---- +import { schema, TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from 'kibana/server'; + +const configSchema = schema.object({ + secret: schema.string({ defaultValue: 'Only on server' }), + uiProp: schema.string({ defaultValue: 'Accessible from client' }), +}); + +type ConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + uiProp: true, + }, + schema: configSchema, +}; +---- + +Configuration containing only the exposed properties will be then +available on the client-side using the plugin's `initializerContext`: + +*my_plugin/public/index.ts* +[source,typescript] +---- +interface ClientConfigType { + uiProp: string; +} + +export class MyPlugin implements Plugin { + constructor(private readonly initializerContext: PluginInitializerContext) {} + + public async setup(core: CoreSetup, deps: {}) { + const config = this.initializerContext.config.get(); + } +---- + +All plugins are considered enabled by default. If you want to disable +your plugin, you could declare the `enabled` flag in the plugin +config. This is a special {kib} Platform key. {kib} reads its +value and won’t create a plugin instance if `enabled: false`. + +[source,js] +---- +export const config = { + schema: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), +}; +---- +[[handle-plugin-configuration-deprecations]] +===== Handle plugin configuration deprecations +If your plugin has deprecated configuration keys, you can describe them using +the `deprecations` config descriptor field. +Deprecations are managed on a per-plugin basis, meaning you don’t need to specify +the whole property path, but use the relative path from your plugin’s +configuration root. + +*my_plugin/server/index.ts* +[source,typescript] +---- +import { schema, TypeOf } from '@kbn/config-schema'; +import type { PluginConfigDescriptor } from 'kibana/server'; + +const configSchema = schema.object({ + newProperty: schema.string({ defaultValue: 'Some string' }), +}); + +type ConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + deprecations: ({ rename, unused }) => [ + rename('oldProperty', 'newProperty'), + unused('someUnusedProperty'), + ], +}; +---- + +In some cases, accessing the whole configuration for deprecations is +necessary. For these edge cases, `renameFromRoot` and `unusedFromRoot` +are also accessible when declaring deprecations. + +*my_plugin/server/index.ts* +[source,typescript] +---- +export const config: PluginConfigDescriptor = { + schema: configSchema, + deprecations: ({ renameFromRoot, unusedFromRoot }) => [ + renameFromRoot('oldplugin.property', 'myplugin.property'), + unusedFromRoot('oldplugin.deprecated'), + ], +}; +---- +==== Logging service +Allows a plugin to provide status and diagnostic information. +For detailed instructions see the {kib-repo}blob/{branch}/src/core/server/logging/README.md[logging service documentation]. + +[source,typescript] +---- +import type { PluginInitializerContext, CoreSetup, Plugin, Logger } from 'kibana/server'; + +export class MyPlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + try { + this.logger.debug('doing something...'); + // … + } catch (e) { + this.logger.error('failed doing something...'); + } + } +} +---- + +==== Elasticsearch service +`Elasticsearch service` provides `elasticsearch.client` program API to communicate with Elasticsearch server REST API. +`elasticsearch.client` interacts with Elasticsearch service on behalf of: + +- `kibana_system` user via `elasticsearch.client.asInternalUser.*` methods. +- a current end-user via `elasticsearch.client.asCurrentUser.*` methods. In this case Elasticsearch client should be given the current user credentials. +See <> and <>. + +{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md[Elasticsearch service API docs] + +[source,typescript] +---- +import { CoreStart, Plugin } from 'kibana/public'; + +export class MyPlugin implements Plugin { + public start(core: CoreStart) { + async function asyncTask() { + const result = await core.elasticsearch.client.asInternalUser.ping(…); + } + asyncTask(); + } +} +---- + +For advanced use-cases, such as a search, use {kib-repo}blob/{branch}/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md[Data plugin] + +include::saved-objects-service.asciidoc[leveloffset=+1] + +==== HTTP service +Allows plugins: + +* to extend the {kib} server with custom REST API. +* to execute custom logic on an incoming request or server response. +* implement custom authentication and authorization strategy. + +See {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md[HTTP service API docs] + +[source,typescript] +---- +import { schema } from '@kbn/config-schema'; +import type { CoreSetup, Plugin } from 'kibana/server'; + +export class MyPlugin implements Plugin { + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + + const validate = { + params: schema.object({ + id: schema.string(), + }), + }; + + router.get({ + path: 'my_plugin/{id}', + validate + }, + async (context, request, response) => { + const data = await findObject(request.params.id); + if (!data) return response.notFound(); + return response.ok({ + body: data, + headers: { + 'content-type': 'application/json' + } + }); + }); + } +} +---- + +==== UI settings service +The program interface to <>. +It makes it possible for Kibana plugins to extend Kibana UI Settings Management with custom settings. + +See: + +- {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.uisettingsservicesetup.register.md[UI settings service Setup API docs] +- {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.uisettingsservicestart.register.md[UI settings service Start API docs] + +[source,typescript] +---- +import { schema } from '@kbn/config-schema'; +import type { CoreSetup,Plugin } from 'kibana/server'; + +export class MyPlugin implements Plugin { + public setup(core: CoreSetup) { + core.uiSettings.register({ + custom: { + value: '42', + schema: schema.string(), + }, + }); + const router = core.http.createRouter(); + router.get({ + path: 'my_plugin/{id}', + validate: …, + }, + async (context, request, response) => { + const customSetting = await context.uiSettings.client.get('custom'); + … + }); + } +} + +---- + +=== Client-side +==== Application service +Kibana has migrated to be a Single Page Application. Plugins should use `Application service` API to instruct Kibana what an application should be loaded & rendered in the UI in response to user interactions. +[source,typescript] +---- +import { AppMountParameters, CoreSetup, Plugin, DEFAULT_APP_CATEGORIES } from 'kibana/public'; + +export class MyPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ // <1> + category: DEFAULT_APP_CATEGORIES.kibana, + id: 'my-plugin', + title: 'my plugin title', + euiIconType: '/path/to/some.svg', + order: 100, + appRoute: '/app/my_plugin', // <2> + async mount(params: AppMountParameters) { // <3> + // Load application bundle + const { renderApp } = await import('./application'); + // Get start services + const [coreStart, depsStart] = await core.getStartServices(); // <4> + // Render the application + return renderApp(coreStart, depsStart, params); // <5> + }, + }); + } +} +---- +<1> See {kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md[application.register interface] +<2> Application specific URL. +<3> `mount` callback is invoked when a user navigates to the application-specific URL. +<4> `core.getStartServices` method provides API available during `start` lifecycle. +<5> `mount` method must return a function that will be called to unmount the application. + +NOTE:: you are free to use any UI library to render a plugin application in DOM. +However, we recommend using React and https://elastic.github.io/eui[EUI] for all your basic UI +components to create a consistent UI experience. + +==== HTTP service +Provides API to communicate with the {kib} server. Feel free to use another HTTP client library to request 3rd party services. + +[source,typescript] +---- +import { CoreStart } from 'kibana/public'; +interface ResponseType {…}; +async function fetchData(core: CoreStart) { + return await core.http.get<>( + '/api/my_plugin/', + { query: … }, + ); +} +---- +See {kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.httpsetup.md[for all available API]. + +==== Elasticsearch service +Not available in the browser. Use {kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md[Data plugin] instead. + +== Patterns +[[scoped-services]] +=== Scoped services +Whenever Kibana needs to get access to data saved in elasticsearch, it +should perform a check whether an end-user has access to the data. In +the legacy platform, Kibana requires binding elasticsearch related API +with an incoming request to access elasticsearch service on behalf of a +user. + +[source,js] +---- +async function handler(req, res) { + const dataCluster = server.plugins.elasticsearch.getCluster('data'); + const data = await dataCluster.callWithRequest(req, 'ping'); +} +---- + +The Kibana Platform introduced a handler interface on the server-side to perform that association +internally. Core services, that require impersonation with an incoming +request, are exposed via `context` argument of +{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.requesthandler.md[the +request handler interface.] The above example looks in the Kibana Platform +as + +[source,js] +---- +async function handler(context, req, res) { + const data = await context.core.elasticsearch.client.asCurrentUser('ping'); +} +---- + +The +{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md[request +handler context] exposed the next scoped *core* services: + +[width="100%",cols="30%,70%",options="header",] +|=== +|Legacy Platform |Kibana Platform +|`request.getSavedObjectsClient` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md[`context.savedObjects.client`] + +|`server.plugins.elasticsearch.getCluster('admin')` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md[`context.elasticsearch.client`] + +|`server.plugins.elasticsearch.getCluster('data')` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md[`context.elasticsearch.client`] + +|`request.getUiSettingsService` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iuisettingsclient.md[`context.uiSettings.client`] +|=== + +==== Declare a custom scoped service + +Plugins can extend the handler context with a custom API that will be +available to the plugin itself and all dependent plugins. For example, +the plugin creates a custom elasticsearch client and wants to use it via +the request handler context: + +[source,typescript] +---- +import type { CoreSetup, IScopedClusterClient } from 'kibana/server'; + +export interface MyPluginContext { + client: IScopedClusterClient; +} + +// extend RequestHandlerContext when a dependent plugin imports MyPluginContext from the file +declare module 'kibana/server' { + interface RequestHandlerContext { + myPlugin?: MyPluginContext; + } +} + +class MyPlugin { + setup(core: CoreSetup) { + const client = core.elasticsearch.createClient('myClient'); + core.http.registerRouteHandlerContext('myPlugin', (context, req, res) => { + return { client: client.asScoped(req) }; + }); + const router = core.http.createRouter(); + router.get( + { path: '/api/my-plugin/', validate: … }, + async (context, req, res) => { + const data = await context.myPlugin.client.asCurrentUser('endpoint'); + } + ); + } +---- diff --git a/docs/developer/architecture/development-plugin-saved-objects.asciidoc b/docs/developer/architecture/core/saved-objects-service.asciidoc similarity index 98% rename from docs/developer/architecture/development-plugin-saved-objects.asciidoc rename to docs/developer/architecture/core/saved-objects-service.asciidoc index 0d31f5d90f66..047c3dffa635 100644 --- a/docs/developer/architecture/development-plugin-saved-objects.asciidoc +++ b/docs/developer/architecture/core/saved-objects-service.asciidoc @@ -1,7 +1,7 @@ -[[development-plugin-saved-objects]] -== Using Saved Objects +[[saved-objects-service]] +== Saved Objects service -Saved Objects allow {kib} plugins to use {es} like a primary +`Saved Objects service` allows {kib} plugins to use {es} like a primary database. Think of it as an Object Document Mapper for {es}. Once a plugin has registered one or more Saved Object types, the Saved Objects client can be used to query or perform create, read, update and delete operations on diff --git a/docs/developer/architecture/index.asciidoc b/docs/developer/architecture/index.asciidoc index dc15b90b69d1..7fa7d80ef972 100644 --- a/docs/developer/architecture/index.asciidoc +++ b/docs/developer/architecture/index.asciidoc @@ -3,9 +3,15 @@ [IMPORTANT] ============================================== -{kib} developer services and apis are in a state of constant development. We cannot provide backwards compatibility at this time due to the high rate of change. +The {kib} Plugin APIs are in a state of +constant development. We cannot provide backwards compatibility at this time due +to the high rate of change. ============================================== +To begin plugin development, we recommend reading our overview of how plugins work: + +* <> + Our developer services are changing all the time. One of the best ways to discover and learn about them is to read the available READMEs from all the plugins inside our {kib-repo}tree/{branch}/src/plugins[open source plugins folder] and our {kib-repo}/tree/{branch}/x-pack/plugins[commercial plugins folder]. @@ -14,17 +20,17 @@ A few services also automatically generate api documentation which can be browse A few notable services are called out below. +* <> * <> -* <> * <> * <> -include::security/index.asciidoc[leveloffset=+1] +include::kibana-platform-plugin-api.asciidoc[leveloffset=+1] -include::development-plugin-saved-objects.asciidoc[leveloffset=+1] +include::core/index.asciidoc[leveloffset=+1] + +include::security/index.asciidoc[leveloffset=+1] include::add-data-tutorials.asciidoc[leveloffset=+1] include::development-visualize-index.asciidoc[leveloffset=+1] - - diff --git a/docs/developer/architecture/kibana-platform-plugin-api.asciidoc b/docs/developer/architecture/kibana-platform-plugin-api.asciidoc new file mode 100644 index 000000000000..2005a90bb87b --- /dev/null +++ b/docs/developer/architecture/kibana-platform-plugin-api.asciidoc @@ -0,0 +1,347 @@ +[[kibana-platform-plugin-api]] +== {kib} Plugin API + +experimental[] + +{kib} platform plugins are a significant step toward stabilizing {kib} architecture for all the developers. +We made sure plugins could continue to use most of the same technologies they use today, at least from a technical perspective. + +=== Anatomy of a plugin + +Plugins are defined as classes and present themselves to {kib} +through a simple wrapper function. A plugin can have browser-side code, +server-side code, or both. There is no architectural difference between +a plugin in the browser and a plugin on the server. +In both places, you describe your plugin similarly, and you interact with +Core and other plugins in the same way. + +The basic file structure of a {kib} plugin named `demo` that +has both client-side and server-side code would be: + +[source,tree] +---- +plugins/ + demo + kibana.json [1] + public + index.ts [2] + plugin.ts [3] + server + index.ts [4] + plugin.ts [5] +---- + +*[1] `kibana.json`* is a static manifest file that is used to identify the +plugin and to specify if this plugin has server-side code, browser-side code, or both: + +[source,json] +---- +{ + "id": "demo", + "version": "kibana", + "server": true, + "ui": true +} +---- + +Learn about the {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[manifest +file format]. + +NOTE: `package.json` files are irrelevant to and ignored by {kib} for discovering and loading plugins. + +*[2] `public/index.ts`* is the entry point into the client-side code of +this plugin. It must export a function named `plugin`, which will +receive {kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.plugininitializercontext.md[a standard set of core capabilities] as an argument. +It should return an instance of its plugin class for +{kib} to load. + +[source,typescript] +---- +import type { PluginInitializerContext } from 'kibana/server'; +import { MyPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new MyPlugin(initializerContext); +} +---- + +*[3] `public/plugin.ts`* is the client-side plugin definition itself. +Technically speaking, it does not need to be a class or even a separate +file from the entry point, but _all plugins at Elastic_ should be +consistent in this way. See all {kib-repo}blob/{branch}/src/core/CONVENTIONS.md[conventions +for first-party Elastic plugins]. + +[source,typescript] +---- +import type { Plugin, PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; + +export class MyPlugin implements Plugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup) { + // called when plugin is setting up during Kibana's startup sequence + } + + public start(core: CoreStart) { + // called after all plugins are set up + } + + public stop() { + // called when plugin is torn down during Kibana's shutdown sequence + } +} +---- + +*[4] `server/index.ts`* is the entry-point into the server-side code of +this plugin. {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md[It is identical] in almost every way to the client-side +entry-point: + + +[source,typescript] +---- +import type { PluginInitializerContext } from 'kibana/server'; +import { MyPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new MyPlugin(initializerContext); +} +---- + +*[5] `server/plugin.ts`* is the server-side plugin definition. The +shape of this plugin is the same as it’s client-side counter-part: + +[source,typescript] +---- +import type { Plugin, PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; + +export class MyPlugin implements Plugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup) { + // called when plugin is setting up during Kibana's startup sequence + } + + public start(core: CoreStart) { + // called after all plugins are set up + } + + public stop() { + // called when plugin is torn down during Kibana's shutdown sequence + } +} +---- + +{kib} does not impose any technical restrictions on how the +the internals of a plugin are architected, though there are certain +considerations related to how plugins integrate with core APIs +and APIs exposed by other plugins that may greatly impact how +they are built. +[[plugin-lifecycles]] +=== Lifecycles & Core Services + +The various independent domains that makeup `core` are represented by a +series of services and many of those services expose public interfaces +that are provided to all plugins. Services expose different features +at different parts of their lifecycle. We describe the lifecycle of +core services and plugins with specifically-named functions on the +service definition. + +{kib} has three lifecycles: `setup`, +`start`, and `stop`. Each plugin's `setup` functions is called sequentially +while Kibana is setting up on the server or when it is being loaded in +the browser. The `start` functions are called sequentially after `setup` +has been completed for all plugins. The `stop` functions are called +sequentially while Kibana is gracefully shutting down the server or +when the browser tab or window is being closed. + +The table below explains how each lifecycle relates to the state +of Kibana. + +[width="100%",cols="10%, 15%, 37%, 38%",options="header",] +|=== +|lifecycle | purpose| server |browser +|_setup_ +|perform "registration" work to setup environment for runtime +|configure REST API endpoint, register saved object types, etc. +|configure application routes in SPA, register custom UI elements in extension points, etc. + +|_start_ +|bootstrap runtime logic +|respond to an incoming request, request Elasticsearch server, etc. +|start polling Kibana server, update DOM tree in response to user interactions, etc. + +|_stop_ +|cleanup runtime +|dispose of active handles before the server shutdown. +|store session data in the LocalStorage when the user navigates away from {kib}, etc. +|=== + +There is no equivalent behavior to `start` or `stop` in legacy plugins. +Conversely, there is no equivalent to `uiExports` in Kibana Platform plugins. +As a general rule of thumb, features that were registered via `uiExports` are +now registered during the `setup` phase. Most of everything else should move +to the `start` phase. + +The lifecycle-specific contracts exposed by core services are always +passed as the first argument to the equivalent lifecycle function in a +plugin. For example, the core `http` service exposes a function +`createRouter` to all plugin `setup` functions. To use this function to register +an HTTP route handler, a plugin just accesses it off of the first argument: + +[source, typescript] +---- +import type { CoreSetup } from 'kibana/server'; + +export class MyPlugin { + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + // handler is called when '/path' resource is requested with `GET` method + router.get({ path: '/path', validate: false }, (context, req, res) => res.ok({ content: 'ok' })); + } +} +---- + +Different service interfaces can and will be passed to `setup`, `start`, and +`stop` because certain functionality makes sense in the context of a +running plugin while other types of functionality may have restrictions +or may only make sense in the context of a plugin that is stopping. + +For example, the `stop` function in the browser gets invoked as part of +the `window.onbeforeunload` event, which means you can’t necessarily +execute asynchronous code here reliably. For that reason, +`core` likely wouldn’t provide any asynchronous functions to plugin +`stop` functions in the browser. + +The current lifecycle function for all plugins will be executed before the next +lifecycle starts. That is to say that all `setup` functions are executed before +any `start` functions are executed. + +These are the contracts exposed by the core services for each lifecycle: + +[cols=",,",options="header",] +|=== +|lifecycle |server contract|browser contract +|_contructor_ +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md[PluginInitializerContext] +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.plugininitializercontext.md[PluginInitializerContext] + +|_setup_ +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.coresetup.md[CoreSetup] +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.coresetup.md[CoreSetup] + +|_start_ +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.corestart.md[CoreStart] +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.corestart.md[CoreStart] + +|_stop_ | +|=== + +=== Integrating with other plugins + +Plugins can expose public interfaces for other plugins to consume. Like +`core`, those interfaces are bound to the lifecycle functions `setup` +and/or `start`. + +Anything returned from `setup` or `start` will act as the interface, and +while not a technical requirement, all first-party Elastic plugins +will expose types for that interface as well. Third party plugins +wishing to allow other plugins to integrate with it are also highly +encouraged to expose types for their plugin interfaces. + +*foobar plugin.ts:* + +[source, typescript] +---- +import type { Plugin } from 'kibana/server'; +export interface FoobarPluginSetup { <1> + getFoo(): string; +} + +export interface FoobarPluginStart { <1> + getBar(): string; +} + +export class MyPlugin implements Plugin { + public setup(): FoobarPluginSetup { + return { + getFoo() { + return 'foo'; + }, + }; + } + + public start(): FoobarPluginStart { + return { + getBar() { + return 'bar'; + }, + }; + } +} +---- +<1> We highly encourage plugin authors to explicitly declare public interfaces for their plugins. + +Unlike core, capabilities exposed by plugins are _not_ automatically +injected into all plugins. Instead, if a plugin wishes to use the public +interface provided by another plugin, it must first declare that +plugin as a dependency in it's {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`kibana.json`] manifest file. + +*demo kibana.json:* + +[source,json] +---- +{ + "id": "demo", + "requiredPlugins": ["foobar"], + "server": true, + "ui": true +} +---- + +With that specified in the plugin manifest, the appropriate interfaces +are then available via the second argument of `setup` and/or `start`: + +*demo plugin.ts:* + +[source,typescript] +---- +import type { CoreSetup, CoreStart } from 'kibana/server'; +import type { FoobarPluginSetup, FoobarPluginStart } from '../../foobar/server'; + +interface DemoSetupPlugins { <1> + foobar: FoobarPluginSetup; +} + +interface DemoStartPlugins { + foobar: FoobarPluginStart; +} + +export class AnotherPlugin { + public setup(core: CoreSetup, plugins: DemoSetupPlugins) { <2> + const { foobar } = plugins; + foobar.getFoo(); // 'foo' + foobar.getBar(); // throws because getBar does not exist + } + + public start(core: CoreStart, plugins: DemoStartPlugins) { <3> + const { foobar } = plugins; + foobar.getFoo(); // throws because getFoo does not exist + foobar.getBar(); // 'bar' + } + + public stop() {} +} +---- +<1> The interface for plugin's dependencies must be manually composed. You can +do this by importing the appropriate type from the plugin and constructing an +interface where the property name is the plugin's ID. +<2> These manually constructed types should then be used to specify the type of +the second argument to the plugin. +<3> Notice that the type for the setup and start lifecycles are different. Plugin lifecycle +functions can only access the APIs that are exposed _during_ that lifecycle. + +=== Migrating legacy plugins + +In Kibana 7.10, support for legacy plugins was removed. See +<> for detailed information on how to convert existing +legacy plugins to this new API. diff --git a/docs/developer/best-practices/index.asciidoc b/docs/developer/best-practices/index.asciidoc index 42b379e60689..b048e59e6c98 100644 --- a/docs/developer/best-practices/index.asciidoc +++ b/docs/developer/best-practices/index.asciidoc @@ -12,6 +12,8 @@ Are you planning with scalability in mind? * Consider data with many fields * Consider data with high cardinality fields * Consider large data sets, that span a long time range +* Are you loading a minimal amount of JS code in the browser? +** See <> for more guidance. * Do you make lots of requests to the server? ** If so, have you considered using the streaming {kib-repo}tree/{branch}/src/plugins/bfetch[bfetch service]? @@ -140,6 +142,8 @@ Review: * <> * <> +include::performance.asciidoc[leveloffset=+1] + include::navigation.asciidoc[leveloffset=+1] include::stability.asciidoc[leveloffset=+1] diff --git a/docs/developer/best-practices/performance.asciidoc b/docs/developer/best-practices/performance.asciidoc new file mode 100644 index 000000000000..70f27005db37 --- /dev/null +++ b/docs/developer/best-practices/performance.asciidoc @@ -0,0 +1,101 @@ +[[plugin-performance]] +== Keep {kib} fast + +*tl;dr*: Load as much code lazily as possible. Everyone loves snappy +applications with a responsive UI and hates spinners. Users deserve the +best experience whether they run {kib} locally or +in the cloud, regardless of their hardware and environment. + +There are 2 main aspects of the perceived speed of an application: loading time +and responsiveness to user actions. {kib} loads and bootstraps *all* +the plugins whenever a user lands on any page. It means that +every new application affects the overall _loading performance_, as plugin code is +loaded _eagerly_ to initialize the plugin and provide plugin API to dependent +plugins. + +However, it’s usually not necessary that the whole plugin code should be loaded +and initialized at once. The plugin could keep on loading code covering API functionality +on {kib} bootstrap, but load UI related code lazily on-demand, when an +application page or management section is mounted. +Always prefer to import UI root components lazily when possible (such as in `mount` +handlers). Even if their size may seem negligible, they are likely using +some heavy-weight libraries that will also be removed from the initial +plugin bundle, therefore, reducing its size by a significant amount. + +[source,typescript] +---- +import type { Plugin, CoreSetup, AppMountParameters } from 'kibana/public'; +export class MyPlugin implements Plugin { + setup(core: CoreSetup, plugins: SetupDeps) { + core.application.register({ + id: 'app', + title: 'My app', + async mount(params: AppMountParameters) { + const { mountApp } = await import('./app/mount_app'); + return mountApp(await core.getStartServices(), params); + }, + }); + plugins.management.sections.section.kibana.registerApp({ + id: 'app', + title: 'My app', + order: 1, + async mount(params) { + const { mountManagementSection } = await import('./app/mount_management_section'); + return mountManagementSection(coreSetup, params); + }, + }); + return { + doSomething() {}, + }; + } +} +---- + +=== Understanding plugin bundle size + +{kib} Platform plugins are pre-built with `@kbn/optimizer` +and distributed as package artifacts. This means that it is no +longer necessary for us to include the `optimizer` in the +distributable version of {kib}. Every plugin artifact contains all +plugin dependencies required to run the plugin, except some +stateful dependencies shared across plugin bundles via +`@kbn/ui-shared-deps`. This means that plugin artifacts _tend to +be larger_ than they were in the legacy platform. To understand the +current size of your plugin artifact, run `@kbn/optimizer` with: + +[source,bash] +---- +node scripts/build_kibana_platform_plugins.js --dist --profile --focus=my_plugin +---- + +and check the output in the `target` sub-folder of your plugin folder: + +[source,bash] +---- +ls -lh plugins/my_plugin/target/public/ +# output +# an async chunk loaded on demand +... 262K 0.plugin.js +# eagerly loaded chunk +... 50K my_plugin.plugin.js +---- + +You might see at least one js bundle - `my_plugin.plugin.js`. This is +the _only_ artifact loaded by {kib} during bootstrap in the +browser. The rule of thumb is to keep its size as small as possible. +Other lazily loaded parts of your plugin will be present in the same folder as +separate chunks under `{number}.myplugin.js` names. If you want to +investigate what your plugin bundle consists of, you need to run +`@kbn/optimizer` with `--profile` flag to generate a +https://webpack.js.org/api/stats/[webpack stats file]. + +[source,bash] +---- +node scripts/build_kibana_platform_plugins.js --dist --no-examples --profile +---- + +Many OSS tools allow you to analyze the generated stats file: + +* http://webpack.github.io/analyse/#modules[An official tool] from +Webpack authors +* https://chrisbateman.github.io/webpack-visualizer/[webpack-visualizer] diff --git a/docs/developer/contributing/development-ci-metrics.asciidoc b/docs/developer/contributing/development-ci-metrics.asciidoc index 485b7af6a622..9c54ef9c8a91 100644 --- a/docs/developer/contributing/development-ci-metrics.asciidoc +++ b/docs/developer/contributing/development-ci-metrics.asciidoc @@ -75,7 +75,7 @@ In order to prevent the page load bundles from growing unexpectedly large we lim In most cases the limit should be high enough that PRs shouldn't trigger overages, but when they do make sure it's clear what is cuasing the overage by trying the following: -1. Run the optimizer locally with the `--profile` flag to produce webpack `stats.json` files for bundles which can be inspected using a number of different online tools. Focus on the chunk named `{pluginId}.plugin.js`; the `*.chunk.js` chunks make up the `async chunks size` metric which is currently unlimited and is the main way that we {kib-repo}blob/{branch}/src/core/MIGRATION.md#keep-kibana-fast[reduce the size of page load chunks]. +1. Run the optimizer locally with the `--profile` flag to produce webpack `stats.json` files for bundles which can be inspected using a number of different online tools. Focus on the chunk named `{pluginId}.plugin.js`; the `*.chunk.js` chunks make up the `async chunks size` metric which is currently unlimited and is the main way that we <>. + [source,shell] ----------- @@ -111,7 +111,7 @@ prettier -w {pluginDir}/target/public/{pluginId}.plugin.js 6. If all else fails reach out to Operations for help. -Once you've identified the files which were added to the build you likely just need to stick them behind an async import as described in {kib-repo}blob/{branch}/src/core/MIGRATION.md#keep-kibana-fast[the MIGRATION.md docs]. +Once you've identified the files which were added to the build you likely just need to stick them behind an async import as described in <>. In the case that the bundle size is not being bloated by anything obvious, but it's still larger than the limit, you can raise the limit in your PR. Do this either by editting the {kib-repo}blob/{branch}/packages/kbn-optimizer/limits.yml[`limits.yml` file] manually or by running the following to have the limit updated to the current size + 15kb diff --git a/docs/developer/getting-started/development-plugin-resources.asciidoc b/docs/developer/getting-started/development-plugin-resources.asciidoc index 1fe211c87c66..863a67f3c42f 100644 --- a/docs/developer/getting-started/development-plugin-resources.asciidoc +++ b/docs/developer/getting-started/development-plugin-resources.asciidoc @@ -51,8 +51,9 @@ but not in the distributable version of {kib}. If you use the [discrete] === {kib} platform migration guide -{kib-repo}blob/{branch}/src/core/MIGRATION.md#migrating-legacy-plugins-to-the-new-platform[This guide] -provides an action plan for moving a legacy plugin to the new platform. +<> +provides an action plan for moving a legacy plugin to the new platform. +<> migration examples for the legacy core services. [discrete] === Externally developed plugins diff --git a/docs/developer/plugin/index.asciidoc b/docs/developer/plugin/index.asciidoc index dd83cf234dea..c74e4c91ef27 100644 --- a/docs/developer/plugin/index.asciidoc +++ b/docs/developer/plugin/index.asciidoc @@ -9,34 +9,16 @@ The {kib} plugin interfaces are in a state of constant development. We cannot p Most developers who contribute code directly to the {kib} repo are writing code inside plugins, so our <> docs are the best place to start. However, there are a few differences when developing plugins outside the {kib} repo. These differences are covered here. -[discrete] -[[automatic-plugin-generator]] -=== Automatic plugin generator - -We recommend that you kick-start your plugin by generating it with the {kib-repo}tree/{branch}/packages/kbn-plugin-generator[Kibana Plugin Generator]. Run the following in the {kib} repo, and you will be asked a couple questions, see some progress bars, and have a freshly generated plugin ready for you to play with in {kib}'s `plugins` folder. - -["source","shell"] ------------ -node scripts/generate_plugin my_plugin_name # replace "my_plugin_name" with your desired plugin name ------------ - -[discrete] -=== Plugin location - -The {kib} directory must be named `kibana`, and your plugin directory should be located in the root of `kibana` in a `plugins` directory, for example: - -["source","shell"] ----- -. -└── kibana - └── plugins - ├── foo-plugin - └── bar-plugin ----- - +* <> +* <> +* <> * <> * <> +* <> +include::plugin-tooling.asciidoc[leveloffset=+1] +include::migrating-legacy-plugins.asciidoc[leveloffset=+1] +include::migrating-legacy-plugins-examples.asciidoc[leveloffset=+1] include::external-plugin-functional-tests.asciidoc[leveloffset=+1] - include::external-plugin-localization.asciidoc[leveloffset=+1] +include::testing-kibana-plugin.asciidoc[leveloffset=+1] diff --git a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc new file mode 100644 index 000000000000..abf51bb3378b --- /dev/null +++ b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc @@ -0,0 +1,1186 @@ +[[migrating-legacy-plugins-examples]] +== Migration Examples + +This document is a list of examples of how to migrate plugin code from +legacy APIs to their {kib} Platform equivalents. + +[[config-migration]] +=== Configuration +==== Declaring config schema + +Declaring the schema of your configuration fields is similar to the +Legacy Platform, but uses the `@kbn/config-schema` package instead of +Joi. This package has full TypeScript support out-of-the-box. + +*Legacy config schema* +[source,typescript] +---- +import Joi from 'joi'; + +new kibana.Plugin({ + config() { + return Joi.object({ + enabled: Joi.boolean().default(true), + defaultAppId: Joi.string().default('home'), + index: Joi.string().default('.kibana'), + disableWelcomeScreen: Joi.boolean().default(false), + autocompleteTerminateAfter: Joi.number().integer().min(1).default(100000), + }) + } +}); +---- + +*{kib} Platform equivalent* +[source,typescript] +---- +import { schema, TypeOf } from '@kbn/config-schema'; + +export const config = { + schema: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + defaultAppId: schema.string({ defaultValue: true }), + index: schema.string({ defaultValue: '.kibana' }), + disableWelcomeScreen: schema.boolean({ defaultValue: false }), + autocompleteTerminateAfter: schema.duration({ min: 1, defaultValue: 100000 }), + }) +}; + +// @kbn/config-schema is written in TypeScript, so you can use your schema +// definition to create a type to use in your plugin code. +export type MyPluginConfig = TypeOf; +---- + +==== Using {kib} config in a new plugin + +After setting the config schema for your plugin, you might want to read +configuration values from your plugin. It is provided as part of the +{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md[PluginInitializerContext] +in the _constructor_ of the plugin: + +*plugins/my_plugin/(public|server)/index.ts* +[source,typescript] +---- +import type { PluginInitializerContext } from 'kibana/server'; +import { MyPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new MyPlugin(initializerContext); +} +---- + +*plugins/my_plugin/(public|server)/plugin.ts* +[source,typescript] +---- +import type { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { CoreSetup, Logger, Plugin, PluginInitializerContext, PluginName } from 'kibana/server'; +import type { MyPluginConfig } from './config'; + +export class MyPlugin implements Plugin { + private readonly config$: Observable; + private readonly log: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.log = initializerContext.logger.get(); + this.config$ = initializerContext.config.create(); + } + + public async setup(core: CoreSetup, deps: Record) { + const isEnabled = await this.config$.pipe(first()).toPromise(); + } +} +---- + +Additionally, some plugins need to access the runtime env configuration. + +[source,typescript] +---- +export class MyPlugin implements Plugin { + public async setup(core: CoreSetup, deps: Record) { + const { mode: { dev }, packageInfo: { version } } = this.initializerContext.env + } +---- + +=== Creating a {kib} Platform plugin + +For example, if you want to move the legacy `demoplugin` plugin's +configuration to the {kib} Platform, you could create the {kib} Platform plugin with the +same name in `plugins/demoplugin` with the following files: + +*plugins/demoplugin/kibana.json* +[source,json5] +---- +{ + "id": "demoplugin", + "server": true +} +---- + +*plugins/demoplugin/server/index.ts* +[source,typescript] +---- +import { schema, TypeOf } from '@kbn/config-schema'; +import type { PluginInitializerContext } from 'kibana/server'; +import { DemoPlugin } from './plugin'; + +export const config = { + schema: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }); +} + +export const plugin = (initContext: PluginInitializerContext) => new DemoPlugin(initContext); + +export type DemoPluginConfig = TypeOf; +export { DemoPluginSetup } from './plugin'; +---- + +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +import type { PluginInitializerContext, Plugin, CoreSetup } from 'kibana/server'; +import type { DemoPluginConfig } from '.'; +export interface DemoPluginSetup {}; + +export class DemoPlugin implements Plugin { + constructor(private readonly initContext: PluginInitializerContext) {} + + public setup(core: CoreSetup) { + return {}; + } + + public start() {} + public stop() {} +} +---- + +[[http-routes-migration]] +=== HTTP Routes + +In the legacy platform, plugins have direct access to the Hapi `server` +object, which gives full access to all of Hapi’s API. In the New +Platform, plugins have access to the +{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md[HttpServiceSetup] +interface, which is exposed via the +{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.coresetup.md[CoreSetup] +object injected into the `setup` method of server-side plugins. + +This interface has a different API with slightly different behaviors. + +* All input (body, query parameters, and URL parameters) must be +validated using the `@kbn/config-schema` package. If no validation +schema is provided, these values will be empty objects. +* All exceptions thrown by handlers result in 500 errors. If you need a +specific HTTP error code, catch any exceptions in your handler and +construct the appropriate response using the provided response factory. +While you can continue using the `Boom` module internally in your +plugin, the framework does not have native support for converting Boom +exceptions into HTTP responses. + +Migrate legacy route registration: +*legacy/plugins/demoplugin/index.ts* +[source,typescript] +---- +import Joi from 'joi'; + +new kibana.Plugin({ + init(server) { + server.route({ + path: '/api/demoplugin/search', + method: 'POST', + options: { + validate: { + payload: Joi.object({ + field1: Joi.string().required(), + }), + } + }, + handler(req, h) { + return { message: `Received field1: ${req.payload.field1}` }; + } + }); + } +}); +---- +to the {kib} platform format: +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +import { schema } from '@kbn/config-schema'; +import type { CoreSetup } from 'kibana/server'; + +export class DemoPlugin { + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + router.post( + { + path: '/api/demoplugin/search', + validate: { + body: schema.object({ + field1: schema.string(), + }), + } + }, + (context, req, res) => { + return res.ok({ + body: { + message: `Received field1: ${req.body.field1}` + } + }); + } + ) + } +} +---- + +If your plugin still relies on throwing Boom errors from routes, you can +use the `router.handleLegacyErrors` as a temporary solution until error +migration is complete: + +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +import { schema } from '@kbn/config-schema'; +import { CoreSetup } from 'kibana/server'; +import Boom from 'boom'; + +export class DemoPlugin { + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + router.post( + { + path: '/api/demoplugin/search', + validate: { + body: schema.object({ + field1: schema.string(), + }), + } + }, + router.handleLegacyErrors((context, req, res) => { + throw Boom.notFound('not there'); // will be converted into proper Platform error + }) + ) + } +} +---- + +=== Accessing Services + +Services in the Legacy Platform were typically available via methods on +either `server.plugins.*`, `server.*`, or `req.*`. In the {kib} Platform, +all services are available via the `context` argument to the route +handler. The type of this argument is the +{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md[RequestHandlerContext]. +The APIs available here will include all Core services and any services registered by plugins this plugin depends on. + +*legacy/plugins/demoplugin/index.ts* +[source,typescript] +---- +new kibana.Plugin({ + init(server) { + const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); + + server.route({ + path: '/api/my-plugin/my-route', + method: 'POST', + async handler(req, h) { + const results = await callWithRequest(req, 'search', query); + return { results }; + } + }); + } +}); +---- + +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +export class DemoPlugin { + public setup(core) { + const router = core.http.createRouter(); + router.post( + { + path: '/api/my-plugin/my-route', + }, + async (context, req, res) => { + const results = await context.core.elasticsearch.client.asCurrentUser.search(query); + return res.ok({ + body: { results } + }); + } + ) + } +} +---- + +=== Migrating Hapi pre-handlers + +In the Legacy Platform, routes could provide a `pre` option in their +config to register a function that should be run before the route +handler. These `pre` handlers allow routes to share some business +logic that may do some pre-work or validation. In {kib}, these are +often used for license checks. + +The {kib} Platform’s HTTP interface does not provide this +functionality. However, it is simple enough to port over using +a higher-order function that can wrap the route handler. + +==== Simple example + +In this simple example, a pre-handler is used to either abort the +request with an error or continue as normal. This is a simple +`gate-keeping` pattern. + +[source,typescript] +---- +// Legacy pre-handler +const licensePreRouting = (request) => { + const licenseInfo = getMyPluginLicenseInfo(request.server.plugins.xpack_main); + if (!licenseInfo.isOneOf(['gold', 'platinum', 'trial'])) { + throw Boom.forbidden(`You don't have the right license for MyPlugin!`); + } +} + +server.route({ + method: 'GET', + path: '/api/my-plugin/do-something', + config: { + pre: [{ method: licensePreRouting }] + }, + handler: (req) => { + return doSomethingInteresting(); + } +}) +---- + +In the {kib} Platform, the same functionality can be achieved by +creating a function that takes a route handler (or factory for a route +handler) as an argument and either successfully invokes it or +returns an error response. + +This a `high-order handler` similar to the `high-order +component` pattern common in the React ecosystem. + +[source,typescript] +---- +// Kibana Platform high-order handler +const checkLicense = ( + handler: RequestHandler +): RequestHandler => { + return (context, req, res) => { + const licenseInfo = getMyPluginLicenseInfo(context.licensing.license); + + if (licenseInfo.hasAtLeast('gold')) { + return handler(context, req, res); + } else { + return res.forbidden({ body: `You don't have the right license for MyPlugin!` }); + } + } +} + +router.get( + { path: '/api/my-plugin/do-something', validate: false }, + checkLicense(async (context, req, res) => { + const results = doSomethingInteresting(); + return res.ok({ body: results }); + }), +) +---- + +==== Full Example + +In some cases, the route handler may need access to data that the +pre-handler retrieves. In this case, you can utilize a handler _factory_ +rather than a raw handler. + +[source,typescript] +---- +// Legacy pre-handler +const licensePreRouting = (request) => { + const licenseInfo = getMyPluginLicenseInfo(request.server.plugins.xpack_main); + if (licenseInfo.isOneOf(['gold', 'platinum', 'trial'])) { + // In this case, the return value of the pre-handler is made available on + // whatever the 'assign' option is in the route config. + return licenseInfo; + } else { + // In this case, the route handler is never called and the user gets this + // error message + throw Boom.forbidden(`You don't have the right license for MyPlugin!`); + } +} + +server.route({ + method: 'GET', + path: '/api/my-plugin/do-something', + config: { + pre: [{ method: licensePreRouting, assign: 'licenseInfo' }] + }, + handler: (req) => { + const licenseInfo = req.pre.licenseInfo; + return doSomethingInteresting(licenseInfo); + } +}) +---- + +In many cases, it may be simpler to duplicate the function call to +retrieve the data again in the main handler. In other cases, you +can utilize a handler _factory_ rather than a raw handler as the +argument to your high-order handler. This way, the high-order handler can +pass arbitrary arguments to the route handler. + +[source,typescript] +---- +// Kibana Platform high-order handler +const checkLicense = ( + handlerFactory: (licenseInfo: MyPluginLicenseInfo) => RequestHandler +): RequestHandler => { + return (context, req, res) => { + const licenseInfo = getMyPluginLicenseInfo(context.licensing.license); + + if (licenseInfo.hasAtLeast('gold')) { + const handler = handlerFactory(licenseInfo); + return handler(context, req, res); + } else { + return res.forbidden({ body: `You don't have the right license for MyPlugin!` }); + } + } +} + +router.get( + { path: '/api/my-plugin/do-something', validate: false }, + checkLicense(licenseInfo => async (context, req, res) => { + const results = doSomethingInteresting(licenseInfo); + return res.ok({ body: results }); + }), +) +---- + +=== Chrome + +In the Legacy Platform, the `ui/chrome` import contained APIs for a very +wide range of features. In the {kib} Platform, some of these APIs have +changed or moved elsewhere. See <>. + +==== Updating an application navlink + +In the legacy platform, the navlink could be updated using +`chrome.navLinks.update`. + +[source,typescript] +---- +uiModules.get('xpack/ml').run(() => { + const showAppLink = xpackInfo.get('features.ml.showLinks', false); + const isAvailable = xpackInfo.get('features.ml.isAvailable', false); + + const navLinkUpdates = { + // hide by default, only show once the xpackInfo is initialized + hidden: !showAppLink, + disabled: !showAppLink || (showAppLink && !isAvailable), + }; + + npStart.core.chrome.navLinks.update('ml', navLinkUpdates); +}); +---- + +In the {kib} Platform, navlinks should not be updated directly. Instead, +it is now possible to add an `updater` when registering an application +to change the application or the navlink state at runtime. + +[source,typescript] +---- +// my_plugin has a required dependencie to the `licensing` plugin +interface MyPluginSetupDeps { + licensing: LicensingPluginSetup; +} + +export class MyPlugin implements Plugin { + setup({ application }, { licensing }: MyPluginSetupDeps) { + const updater$ = licensing.license$.pipe( + map(license => { + const { hidden, disabled } = calcStatusFor(license); + if (hidden) return { navLinkStatus: AppNavLinkStatus.hidden }; + if (disabled) return { navLinkStatus: AppNavLinkStatus.disabled }; + return { navLinkStatus: AppNavLinkStatus.default }; + }) + ); + + application.register({ + id: 'my-app', + title: 'My App', + updater$, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } +---- + +=== Chromeless Applications + +In {kib}, a `chromeless` application is one where the primary {kib} +UI components such as header or navigation can be hidden. In the legacy +platform, these were referred to as `hidden` applications and were set +via the `hidden` property in a {kib} plugin. Chromeless applications +are also not displayed in the left navbar. + +To mark an application as chromeless, specify `chromeless: false` when +registering your application to hide the chrome UI when the application +is mounted: + +[source,typescript] +---- +application.register({ + id: 'chromeless', + chromeless: true, + async mount(context, params) { + /* ... */ + }, +}); +---- + +If you wish to render your application at a route that does not follow +the `/app/${appId}` pattern, this can be done via the `appRoute` +property. Doing this currently requires you to register a server route +where you can return a bootstrapped HTML page for your application +bundle. + +[source,typescript] +---- +application.register({ + id: 'chromeless', + appRoute: '/chromeless', + chromeless: true, + async mount(context, params) { + /* ... */ + }, +}); +---- + +[[render-html-migration]] +=== Render HTML Content + +You can return a blank HTML page bootstrapped with the core application +bundle from an HTTP route handler via the `httpResources` service. You +may wish to do this if you are rendering a chromeless application with a +custom application route or have other custom rendering needs. + +[source,typescript] +---- +httpResources.register( + { path: '/chromeless', validate: false }, + (context, request, response) => { + //... some logic + return response.renderCoreApp(); + } +); +---- + +You can also exclude user data from the bundle metadata. User +data comprises all UI Settings that are _user provided_, then injected +into the page. You may wish to exclude fetching this data if not +authorized or to slim the page size. + +[source,typescript] +---- +httpResources.register( + { path: '/', validate: false, options: { authRequired: false } }, + (context, request, response) => { + //... some logic + return response.renderAnonymousCoreApp(); + } +); +---- + +[[saved-objects-migration]] +=== Saved Objects types + +In the legacy platform, saved object types were registered using static +definitions in the `uiExports` part of the plugin manifest. + +In the {kib} Platform, all these registrations are performed +programmatically during your plugin’s `setup` phase, using the core +`savedObjects`’s `registerType` setup API. + +The most notable difference is that in the {kib} Platform, the type +registration is performed in a single call to `registerType`, passing a +new `SavedObjectsType` structure that is a superset of the legacy +`schema`, `migrations` `mappings` and `savedObjectsManagement`. + +==== Concrete example + +Suppose you have the following in a legacy plugin: + +*legacy/plugins/demoplugin/index.ts* +[source,js] +---- +import mappings from './mappings.json'; +import { migrations } from './migrations'; + +new kibana.Plugin({ + init(server){ + // [...] + }, + uiExports: { + mappings, + migrations, + savedObjectSchemas: { + 'first-type': { + isNamespaceAgnostic: true, + }, + 'second-type': { + isHidden: true, + }, + }, + savedObjectsManagement: { + 'first-type': { + isImportableAndExportable: true, + icon: 'myFirstIcon', + defaultSearchField: 'title', + getTitle(obj) { + return obj.attributes.title; + }, + getEditUrl(obj) { + return `/some-url/${encodeURIComponent(obj.id)}`; + }, + }, + 'second-type': { + isImportableAndExportable: false, + icon: 'mySecondIcon', + getTitle(obj) { + return obj.attributes.myTitleField; + }, + getInAppUrl(obj) { + return { + path: `/some-url/${encodeURIComponent(obj.id)}`, + uiCapabilitiesPath: 'myPlugin.myType.show', + }; + }, + }, + }, + }, +}) +---- + +*legacy/plugins/demoplugin/mappings.json* +[source,json] +---- +{ + "first-type": { + "properties": { + "someField": { + "type": "text" + }, + "anotherField": { + "type": "text" + } + } + }, + "second-type": { + "properties": { + "textField": { + "type": "text" + }, + "boolField": { + "type": "boolean" + } + } + } +} +---- +*legacy/plugins/demoplugin/migrations.js* +[source,js] +---- +export const migrations = { + 'first-type': { + '1.0.0': migrateFirstTypeToV1, + '2.0.0': migrateFirstTypeToV2, + }, + 'second-type': { + '1.5.0': migrateSecondTypeToV15, + } +} +---- + +To migrate this, you have to regroup the declaration per-type. + +First type: +*plugins/demoplugin/server/saved_objects/first_type.ts* +[source,typescript] +---- +import type { SavedObjectsType } from 'kibana/server'; + +export const firstType: SavedObjectsType = { + name: 'first-type', + hidden: false, + namespaceType: 'agnostic', + mappings: { + properties: { + someField: { + type: 'text', + }, + anotherField: { + type: 'text', + }, + }, + }, + migrations: { + '1.0.0': migrateFirstTypeToV1, + '2.0.0': migrateFirstTypeToV2, + }, + management: { + importableAndExportable: true, + icon: 'myFirstIcon', + defaultSearchField: 'title', + getTitle(obj) { + return obj.attributes.title; + }, + getEditUrl(obj) { + return `/some-url/${encodeURIComponent(obj.id)}`; + }, + }, +}; +---- + +Second type: +*plugins/demoplugin/server/saved_objects/second_type.ts* +[source,typescript] +---- +import type { SavedObjectsType } from 'kibana/server'; + +export const secondType: SavedObjectsType = { + name: 'second-type', + hidden: true, + namespaceType: 'single', + mappings: { + properties: { + textField: { + type: 'text', + }, + boolField: { + type: 'boolean', + }, + }, + }, + migrations: { + '1.5.0': migrateSecondTypeToV15, + }, + management: { + importableAndExportable: false, + icon: 'mySecondIcon', + getTitle(obj) { + return obj.attributes.myTitleField; + }, + getInAppUrl(obj) { + return { + path: `/some-url/${encodeURIComponent(obj.id)}`, + uiCapabilitiesPath: 'myPlugin.myType.show', + }; + }, + }, +}; +---- + +Registration in the plugin’s setup phase: +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +import { firstType, secondType } from './saved_objects'; + +export class DemoPlugin implements Plugin { + setup({ savedObjects }) { + savedObjects.registerType(firstType); + savedObjects.registerType(secondType); + } +} +---- + +==== Changes in structure compared to legacy + +The {kib} Platform `registerType` expected input is very close to the legacy format. +However, there are some minor changes: + +* The `schema.isNamespaceAgnostic` property has been renamed: +`SavedObjectsType.namespaceType`. It no longer accepts a boolean but +instead an enum of `single`, `multiple`, or `agnostic` (see +{kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md[SavedObjectsNamespaceType]). +* The `schema.indexPattern` was accepting either a `string` or a +`(config: LegacyConfig) => string`. `SavedObjectsType.indexPattern` only +accepts a string, as you can access the configuration during your +plugin’s setup phase. +* The `savedObjectsManagement.isImportableAndExportable` property has +been renamed: `SavedObjectsType.management.importableAndExportable`. +* The migration function signature has changed: In legacy, it used to be +[source,typescript] +---- +`(doc: SavedObjectUnsanitizedDoc, log: SavedObjectsMigrationLogger) => SavedObjectUnsanitizedDoc;` +---- +In {kib} Platform, it is +[source,typescript] +---- +`(doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => SavedObjectUnsanitizedDoc;` +---- + +With context being: + +[source,typescript] +---- +export interface SavedObjectMigrationContext { + log: SavedObjectsMigrationLogger; +} +---- + +The changes is very minor though. The legacy migration: + +[source,js] +---- +const migration = (doc, log) => {...} +---- + +Would be converted to: + +[source,typescript] +---- +const migration: SavedObjectMigrationFn = (doc, { log }) => {...} +---- + +=== UiSettings + +UiSettings defaults registration performed during `setup` phase via +`core.uiSettings.register` API. + +*legacy/plugins/demoplugin/index.js* +[source,js] +---- +uiExports: { + uiSettingDefaults: { + 'my-plugin:my-setting': { + name: 'just-work', + value: true, + description: 'make it work', + category: ['my-category'], + }, + } +} +---- + +*plugins/demoplugin/server/plugin.ts* +[source,typescript] +---- +setup(core: CoreSetup){ + core.uiSettings.register({ + 'my-plugin:my-setting': { + name: 'just-work', + value: true, + description: 'make it work', + category: ['my-category'], + schema: schema.boolean(), + }, + }) +} +---- + +=== Elasticsearch client + +The new elasticsearch client is a thin wrapper around +`@elastic/elasticsearch`’s `Client` class. Even if the API is quite +close to the legacy client {kib} was previously using, there are some +subtle changes to take into account during migration. + +https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html[Official +client documentation] + +==== Client API Changes + +Refer to the +https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/breaking-changes.html[Breaking +changes list] for more information about the changes between the legacy +and new client. + +The most significant changes on the Kibana side for the consumers are the following: + +===== User client accessor +Internal /current user client accessors has been renamed and are now +properties instead of functions: +** `callAsInternalUser('ping')` -> `asInternalUser.ping()` +** `callAsCurrentUser('ping')` -> `asCurrentUser.ping()` +* the API now reflects the `Client`’s instead of leveraging the +string-based endpoint names the `LegacyAPICaller` was using. + +Before: + +[source,typescript] +---- +const body = await client.callAsInternalUser('indices.get', { index: 'id' }); +---- + +After: + +[source,typescript] +---- +const { body } = await client.asInternalUser.indices.get({ index: 'id' }); +---- + +===== Response object +Calling any ES endpoint now returns the whole response object instead +of only the body payload. + +Before: + +[source,typescript] +---- +const body = await legacyClient.callAsInternalUser('get', { id: 'id' }); +---- + +After: + +[source,typescript] +---- +const { body } = await client.asInternalUser.get({ id: 'id' }); +---- + +Note that more information from the ES response is available: + +[source,typescript] +---- +const { + body, // response payload + statusCode, // http status code of the response + headers, // response headers + warnings, // warnings returned from ES + meta // meta information about the request, such as request parameters, number of attempts and so on +} = await client.asInternalUser.get({ id: 'id' }); +---- + +===== Response Type +All API methods are now generic to allow specifying the response body. +type + +Before: + +[source,typescript] +---- +const body: GetResponse = await legacyClient.callAsInternalUser('get', { id: 'id' }); +---- + +After: + +[source,typescript] +---- +// body is of type `GetResponse` +const { body } = await client.asInternalUser.get({ id: 'id' }); +// fallback to `Record` if unspecified +const { body } = await client.asInternalUser.get({ id: 'id' }); +---- + +The new client doesn’t provide exhaustive typings for the response +object yet. You might have to copy response type definitions from the +Legacy Elasticsearch library until the additional announcements. + +[source,typescript] +---- +// Kibana provides a few typings for internal purposes +import type { SearchResponse } from 'kibana/server'; +type SearchSource = {...}; +type SearchBody = SearchResponse; +const { body } = await client.search(...); +interface Info {...} +const { body } = await client.info(...); +---- + +===== Errors +The returned error types changed. + +There are no longer specific errors for every HTTP status code (such as +`BadRequest` or `NotFound`). A generic `ResponseError` with the specific +`statusCode` is thrown instead. + +Before: + +[source,typescript] +---- +import { errors } from 'elasticsearch'; +try { + await legacyClient.callAsInternalUser('ping'); +} catch(e) { + if(e instanceof errors.NotFound) { + // do something + } + if(e.status === 401) {} +} +---- + +After: + +[source,typescript] +---- +import { errors } from '@elastic/elasticsearch'; +try { + await client.asInternalUser.ping(); +} catch(e) { + if(e instanceof errors.ResponseError && e.statusCode === 404) { + // do something + } + // also possible, as all errors got a name property with the name of the class, + // so this slightly better in term of performances + if(e.name === 'ResponseError' && e.statusCode === 404) { + // do something + } + if(e.statusCode === 401) {...} +} +---- + +===== Parameter naming format +The parameter property names changed from camelCase to snake_case + +Even if technically, the JavaScript client accepts both formats, the +TypeScript definitions are only defining snake_case properties. + +Before: + +[source,typescript] +---- +legacyClient.callAsCurrentUser('get', { + id: 'id', + storedFields: ['some', 'fields'], +}) +---- + +After: + +[source,typescript] +---- +client.asCurrentUser.get({ + id: 'id', + stored_fields: ['some', 'fields'], +}) +---- + +===== Request abortion +The request abortion API changed + +All promises returned from the client API calls now have an `abort` +method that can be used to cancel the request. + +Before: + +[source,typescript] +---- +const controller = new AbortController(); +legacyClient.callAsCurrentUser('ping', {}, { + signal: controller.signal, +}) +// later +controller.abort(); +---- + +After: + +[source,typescript] +---- +const request = client.asCurrentUser.ping(); +// later +request.abort(); +---- + +===== Headers +It is now possible to override headers when performing specific API +calls. + +Note that doing so is strongly discouraged due to potential side effects +with the ES service internal behavior when scoping as the internal or as +the current user. + +[source,typescript] +---- +const request = client.asCurrentUser.ping({}, { + headers: { + authorization: 'foo', + custom: 'bar', + } +}); +---- + +===== Functional tests +Functional tests are subject to migration to the new client as well. + +Before: + +[source,typescript] +---- +const client = getService('legacyEs'); +---- + +After: + +[source,typescript] +---- +const client = getService('es'); +---- + +==== Accessing the client from a route handler + +Apart from the API format change, accessing the client from within a +route handler did not change. As it was done for the legacy client, a +preconfigured <> bound to an incoming request is accessible using +the `core` context provider: + +[source,typescript] +---- +router.get( + { + path: '/my-route', + }, + async (context, req, res) => { + const { client } = context.core.elasticsearch; + // call as current user + const res = await client.asCurrentUser.ping(); + // call as internal user + const res2 = await client.asInternalUser.search(options); + return res.ok({ body: 'ok' }); + } +); +---- + +==== Creating a custom client + +Note that the `plugins` option is no longer available on the new +client. As the API is now exhaustive, adding custom endpoints using +plugins should no longer be necessary. + +The API to create custom clients did not change much: + +Before: + +[source,typescript] +---- +const customClient = coreStart.elasticsearch.legacy.createClient('my-custom-client', customConfig); +// do something with the client, such as +await customClient.callAsInternalUser('ping'); +// custom client are closable +customClient.close(); +---- + +After: + +[source,typescript] +---- +const customClient = coreStart.elasticsearch.createClient('my-custom-client', customConfig); +// do something with the client, such as +await customClient.asInternalUser.ping(); +// custom client are closable +customClient.close(); +---- + +If, for any reasons, you still need to reach an endpoint not listed on +the client API, using `request.transport` is still possible: + +[source,typescript] +---- +const { body } = await client.asCurrentUser.transport.request({ + method: 'get', + path: '/my-custom-endpoint', + body: { my: 'payload'}, + querystring: { param: 'foo' } +}) +---- diff --git a/docs/developer/plugin/migrating-legacy-plugins.asciidoc b/docs/developer/plugin/migrating-legacy-plugins.asciidoc new file mode 100644 index 000000000000..337d02b11ee9 --- /dev/null +++ b/docs/developer/plugin/migrating-legacy-plugins.asciidoc @@ -0,0 +1,608 @@ +[[migrating-legacy-plugins]] +== Migrating legacy plugins to the {kib} Platform + +[IMPORTANT] +============================================== +In {kib} 7.10, support for legacy-style {kib} plugins was completely removed. +Moving forward, all plugins must be built on the new {kib} Platform Plugin API. +This guide is intended to assist plugin authors in migrating their legacy plugin +to the {kib} Platform Plugin API. +============================================== + +Make no mistake, it is going to take a lot of work to move certain +plugins to the {kib} Platform. + +The goal of this document is to guide developers through the recommended +process of migrating at a high level. Every plugin is different, so +developers should tweak this plan based on their unique requirements. + +First, we recommend you read <> to get an overview +of how plugins work in the {kib} Platform. Then continue here to follow our +generic plan of action that can be applied to any legacy plugin. + +=== Challenges to overcome with legacy plugins + +{kib} Platform plugins have an identical architecture in the browser and on +the server. Legacy plugins have one architecture that they use in the +browser and an entirely different architecture that they use on the +server. + +This means that there are unique sets of challenges for migrating to the +{kib} Platform, depending on whether the legacy plugin code is on the +server or in the browser. + +==== Challenges on the server + +The general architecture of legacy server-side code is similar to +the {kib} Platform architecture in one important way: most legacy +server-side plugins define an `init` function where the bulk of their +business logic begins, and they access both `core` and +`plugin-provided` functionality through the arguments given to `init`. +Rarely does legacy server-side code share stateful services via import +statements. + +Although not exactly the same, legacy plugin `init` functions behave +similarly today as {kib} Platform `setup` functions. `KbnServer` also +exposes an `afterPluginsInit` method, which behaves similarly to `start`. +There is no corresponding legacy concept of `stop`. + +Despite their similarities, server-side plugins pose a formidable +challenge: legacy core and plugin functionality is retrieved from either +the hapi.js `server` or `request` god objects. Worse, these objects are +often passed deeply throughout entire plugins, which directly couples +business logic with hapi. And the worst of it all is, these objects are +mutable at any time. + +The key challenge to overcome with legacy server-side plugins will +decoupling from hapi. + +==== Challenges in the browser + +The legacy plugin system in the browser is fundamentally incompatible +with the {kib} Platform. There is no client-side plugin definition. There +are no services that get passed to plugins at runtime. There really +isn’t even a concrete notion of `core`. + +When a legacy browser plugin needs to access functionality from another +plugin, say to register a UI section to render within another plugin, it +imports a stateful (global singleton) JavaScript module and performs +some sort of state mutation. Sometimes this module exists inside the +plugin itself, and it gets imported via the `plugin/` webpack alias. +Sometimes this module exists outside the context of plugins entirely and +gets imported via the `ui/` webpack alias. Neither of these concepts +exists in the {kib} Platform. + +Legacy browser plugins rely on the feature known as `uiExports/`, which +integrates directly with our build system to ensure that plugin code is +bundled together in such a way to enable that global singleton module +state. There is no corresponding feature in the {kib} Platform, and in +the fact we intend down the line to build {kib} Platform plugins as immutable +bundles that can not share state in this way. + +The key challenge to overcome with legacy browser-side plugins will be +converting all imports from `plugin/`, `ui/`, `uiExports`, and relative +imports from other plugins into a set of services that originate at +runtime during plugin initialization and get passed around throughout +the business logic of the plugin as function arguments. + +==== Plan of action + +To move a legacy plugin to the new plugin system, the +challenges on the server and in the browser must be addressed. + +The approach and level of effort varies significantly between server and +browser plugins, but at a high level, the approach is the same. + +First, decouple your plugin’s business logic from the dependencies that +are not exposed through the {kib} Platform, hapi.js, and Angular.js. Then +introduce plugin definitions that more accurately reflect how plugins +are defined in the {kib} Platform. Finally, replace the functionality you +consume from the core and other plugins with their {kib} Platform equivalents. + +Once those things are finished for any given plugin, it can officially +be switched to the new plugin system. + +=== Server-side plan of action + +Legacy server-side plugins access functionality from the core and other +plugins at runtime via function arguments, which is similar to how they +must be architected to use the new plugin system. This greatly +simplifies the plan of action for migrating server-side plugins. +The main challenge here is to de-couple plugin logic from hapi.js server and request objects. + +For migration examples, see <>. + +=== Browser-side plan of action + +It is generally a much greater challenge preparing legacy browser-side +code for the {kib} Platform than it is server-side, and as such there are +a few more steps. The level of effort here is proportional to the extent +to which a plugin is dependent on Angular.js. + +To complicate matters further, a significant amount of the business +logic in {kib} client-side code exists inside the `ui/public` +directory (aka ui modules), and all of that must be migrated as well. + +Because the usage of Angular and `ui/public` modules varies widely between +legacy plugins, there is no `one size fits all` solution to migrating +your browser-side code to the {kib} Platform. + +For migration examples, see <>. + +=== Frequently asked questions + +==== Do plugins need to be converted to TypeScript? + +No. That said, the migration process will require a lot of refactoring, +and TypeScript will make this dramatically easier and less risky. + +Although it's not strictly necessary, we encourage any plugin that exposes an extension point to do so +with first-class type support so downstream plugins that _are_ using +TypeScript can depend on those types. + +==== How can I avoid passing core services deeply within my UI component tree? + +Some core services are purely presentational, for example +`core.overlays.openModal()`, where UI +code does need access to these deeply within your application. However, +passing these services down as props throughout your application leads +to lots of boilerplate. To avoid this, you have three options: + +* Use an abstraction layer, like Redux, to decouple your UI code from +core (*this is the highly preferred option*). +* https://github.com/reduxjs/redux-thunk#injecting-a-custom-argument[redux-thunk] +and +https://redux-saga.js.org/docs/api/#createsagamiddlewareoptions[redux-saga] +already have ways to do this. +* Use React Context to provide these services to large parts of your +React tree. +* Create a high-order-component that injects core into a React +component. +* This would be a stateful module that holds a reference to core, but +provides it as props to components with a `withCore(MyComponent)` +interface. This can make testing components simpler. (Note: this module +cannot be shared across plugin boundaries, see above). +* Create a global singleton module that gets imported into each module +that needs it. This module cannot be shared across plugin +boundaries. +https://gist.github.com/epixa/06c8eeabd99da3c7545ab295e49acdc3[Example]. + +If you find that you need many different core services throughout your +application, this might indicate a problem in your code and could lead to pain down the +road. For instance, if you need access to an HTTP Client or +SavedObjectsClient in many places in your React tree, it’s likely that a +data layer abstraction (like Redux) could make developing your plugin +much simpler. + +Without such an abstraction, you will need to mock out core services +throughout your test suite and will couple your UI code very tightly to +core. However, if you can contain all of your integration points with +core to Redux middleware and reducers, you only need to mock core +services once and benefit from being able to change those integrations +with core in one place rather than many. This will become incredibly +handy when core APIs have breaking changes. + +==== How is the 'common' code shared on both the client and the server? + +There is no formal notion of `common` code that can safely be imported +from either client-side or server-side code. However, if a plugin author +wishes to maintain a set of code in their plugin in a single place and +then expose it to both server-side and client-side code, they can do so +by exporting the index files for both the `server` and `public` +directories. + +Plugins _should not_ ever import code from deeply inside another plugin +(e.g. `my_plugin/public/components`) or from other top-level directories +(e.g. `my_plugin/common/constants`) as these are not checked for breaking +changes and are considered unstable and subject to change at any time. +You can have other top-level directories like `my_plugin/common`, but +our tooling will not treat these as a stable API, and linter rules will +prevent importing from these directories _from outside the plugin_. + +The benefit of this approach is that the details of where code lives and +whether it is accessible in multiple runtimes is an implementation +detail of the plugin itself. A plugin consumer that is writing +client-side code only ever needs to concern themselves with the +client-side contracts being exposed, and the same can be said for +server-side contracts on the server. + +A plugin author, who decides some set of code should diverge from having +a single `common` definition, can now safely change the implementation +details without impacting downstream consumers. + +==== How do I find {kib} Platform services? + +Most of the utilities you used to build legacy plugins are available +in the {kib} Platform or {kib} Platform plugins. To help you find the new +home for new services, use the tables below to find where the {kib} +Platform equivalent lives. + +===== Client-side +====== Core services + +In client code, `core` can be imported in legacy plugins via the +`ui/new_platform` module. + +[[client-side-core-migration-table]] +[width="100%",cols="15%,85%",options="header",] +|=== +|Legacy Platform |{kib} Platform +|`chrome.addBasePath` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.ibasepath.md[`core.http.basePath.prepend`] + +|`chrome.breadcrumbs.set` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.chromestart.setbreadcrumbs.md[`core.chrome.setBreadcrumbs`] + +|`chrome.getUiSettingsClient` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.corestart.uisettings.md[`core.uiSettings`] + +|`chrome.helpExtension.set` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.chromestart.sethelpextension.md[`core.chrome.setHelpExtension`] + +|`chrome.setVisible` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.chromestart.setisvisible.md[`core.chrome.setIsVisible`] + +|`chrome.getInjected` +| Request Data with your plugin REST HTTP API. + +|`chrome.setRootTemplate` / `chrome.setRootController` +|Use application mounting via {kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md[`core.application.register`] + +|`chrome.navLinks.update` +|{kib-repo}/tree/{branch}/docs/development/core/public/kibana-plugin-core-public.app.updater_.md[`core.appbase.updater`]. Use the `updater$` property when registering your application via +`core.application.register` + +|`import { recentlyAccessed } from 'ui/persisted_log'` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.chromerecentlyaccessed.md[`core.chrome.recentlyAccessed`] + +|`ui/capabilities` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.capabilities.md[`core.application.capabilities`] + +|`ui/documentation_links` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md[`core.docLinks`] + +|`ui/kfetch` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.httpsetup.md[`core.http`] + +|`ui/notify` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.notificationsstart.md[`core.notifications`] +and +{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.overlaystart.md[`core.overlays`]. Toast messages are in `notifications`, banners are in `overlays`. + +|`ui/routes` +|There is no global routing mechanism. Each app +{kib-repo}blob/{branch}/rfcs/text/0004_application_service_mounting.md#complete-example[configures +its own routing]. + +|`ui/saved_objects` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.savedobjectsstart.md[`core.savedObjects`] + +|`ui/doc_title` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.chromedoctitle.md[`core.chrome.docTitle`] + +|`uiExports/injectedVars` / `chrome.getInjected` +|<>. Can only be used to expose configuration properties +|=== + +_See also: +{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.corestart.md[Public’s +CoreStart API Docs]_ + +====== Plugins for shared application services + +In client code, we have a series of plugins that house shared +application services, which are not technically part of `core`, but are +often used in {kib} plugins. + +This table maps some of the most commonly used legacy items to their {kib} +Platform locations. For the API provided by {kib} Plugins see <>. + +[width="100%",cols="15,85",options="header"] +|=== +|Legacy Platform |{kib} Platform +|`import 'ui/apply_filters'` |N/A. Replaced by triggering an +{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.action_global_apply_filter.md[APPLY_FILTER_TRIGGER trigger]. Directive is deprecated. + +|`import 'ui/filter_bar'` +|`import { FilterBar } from 'plugins/data/public'`. Directive is deprecated. + +|`import 'ui/query_bar'` +|`import { QueryStringInput } from 'plugins/data/public'` {kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md[QueryStringInput]. Directives are deprecated. + +|`import 'ui/search_bar'` +|`import { SearchBar } from 'plugins/data/public'` {kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.datapublicpluginstartui.searchbar.md[SearchBar]. Directive is deprecated. + +|`import 'ui/kbn_top_nav'` +|`import { TopNavMenu } from 'plugins/navigation/public'`. Directive was removed. + +|`ui/saved_objects/saved_object_finder` +|`import { SavedObjectFinder } from 'plugins/saved_objects/public'` + +|`core_plugins/interpreter` +|{kib-repo}blob/{branch}/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.md[`plugins.data.expressions`] + +|`ui/courier` +|{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.datapublicpluginsetup.search.md[`plugins.data.search`] + +|`ui/agg_types` +|{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsourcefields.aggs.md[`plugins.data.search.aggs`]. Most code is available for +static import. Stateful code is part of the `search` service. + +|`ui/embeddable` +|{kib-repo}blob/{branch}/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablesetup.md[`plugins.embeddables`] + +|`ui/filter_manager` +|`import { FilterManager } from 'plugins/data/public'` {kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filtermanager.md[`FilterManager`] + +|`ui/index_patterns` +|`import { IndexPatternsService } from 'plugins/data/public'` {kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternsservice.md[IndexPatternsService] + +|`import 'ui/management'` +|`plugins.management.sections`. Management plugin `setup` contract. + +|`import 'ui/registry/field_format_editors'` +|`plugins.indexPatternManagement.fieldFormatEditors` indexPatternManagement plugin `setup` contract. + +|`ui/registry/field_formats` +|{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformats.md[`plugins.data.fieldFormats`] + +|`ui/registry/feature_catalogue` +|`plugins.home.featureCatalogue.register` home plugin `setup` contract + +|`ui/registry/vis_types` +|`plugins.visualizations` + +|`ui/vis` +|`plugins.visualizations` + +|`ui/share` +|`plugins.share`. share plugin `start` contract. `showShareContextMenu` is now called +`toggleShareContextMenu`, `ShareContextMenuExtensionsRegistryProvider` +is now called `register` + +|`ui/vis/vis_factory` +|`plugins.visualizations` + +|`ui/vis/vis_filters` +|`plugins.visualizations.filters` + +|`ui/utils/parse_es_interval` +|`import { search: { aggs: { parseEsInterval } } } from 'plugins/data/public'`. `parseEsInterval`, `ParsedInterval`, `InvalidEsCalendarIntervalError`, +`InvalidEsIntervalFormatError` items were moved to the `Data Plugin` as +a static code +|=== + +===== Server-side + +====== Core services + +In server code, `core` can be accessed from either `server.newPlatform` +or `kbnServer.newPlatform`: + +[width="100%",cols="17, 83",options="header"] +|=== +|Legacy Platform |{kib} Platform +|`server.config()` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md[`initializerContext.config.create()`]. Must also define schema. See <> + +|`server.route` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.createrouter.md[`core.http.createRouter`]. See <>. + +|`server.renderApp()` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.rendercoreapp.md[`response.renderCoreApp()`]. See <>. + +|`server.renderAppWithDefaultConfig()` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderanonymouscoreapp.md[`response.renderAnonymousCoreApp()`]. See <>. + +|`request.getBasePath()` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.basepath.md[`core.http.basePath.get`] + +|`server.plugins.elasticsearch.getCluster('data')` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md[`context.core.elasticsearch.client`] + +|`server.plugins.elasticsearch.getCluster('admin')` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md[`context.core.elasticsearch.client`] + +|`server.plugins.elasticsearch.createCluster(...)` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.createclient.md[`core.elasticsearch.createClient`] + +|`server.savedObjects.setScopedSavedObjectsClientFactory` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md[`core.savedObjects.setClientFactoryProvider`] + +|`server.savedObjects.addScopedSavedObjectsClientWrapperFactory` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.addclientwrapper.md[`core.savedObjects.addClientWrapper`] + +|`server.savedObjects.getSavedObjectsRepository` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md[`core.savedObjects.createInternalRepository`] +{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.createscopedrepository.md[`core.savedObjects.createScopedRepository`] + +|`server.savedObjects.getScopedSavedObjectsClient` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.getscopedclient.md[`core.savedObjects.getScopedClient`] + +|`request.getSavedObjectsClient` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md[`context.core.savedObjects.client`] + +|`request.getUiSettingsService` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.iuisettingsclient.md[`context.core.uiSettings.client`] + +|`kibana.Plugin.deprecations` +|<> and {kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.md[`PluginConfigDescriptor.deprecations`]. Deprecations from {kib} Platform are not applied to legacy configuration + +|`kibana.Plugin.savedObjectSchemas` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`kibana.Plugin.mappings` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`]. Learn more in <>. + +|`kibana.Plugin.migrations` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`]. Learn more in <>. + +|`kibana.Plugin.savedObjectsManagement` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`]. Learn more in <>. +|=== + +_See also: +{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.coresetup.md[Server’s +CoreSetup API Docs]_ + +====== Plugin services + +[width="100%",cols="50%,50%",options="header",] +|=== +|Legacy Platform |{kib} Platform +|`xpack_main.registerFeature` +|{kib-repo}blob/{branch}/x-pack/plugins/features/server/plugin.ts[`plugins.features.registerKibanaFeature`] + +|`xpack_main.feature(pluginID).registerLicenseCheckResultsGenerator` +|{kib-repo}blob/{branch}/x-pack/plugins/licensing/README.md[`x-pack licensing plugin`] +|=== + +===== UI Exports + +The legacy platform used a set of `uiExports` to inject modules from +one plugin into other plugins. This mechanism is not necessary for the +{kib} Platform because _all plugins are executed on the page at once_, +though only one application is rendered at a time. + +This table shows where these uiExports have moved to in the {kib} +Platform. + +[width="100%",cols="15%,85%",options="header"] +|=== +|Legacy Platform |{kib} Platform +|`aliases` +|`N/A`. + +|`app` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md[`core.application.register`] + +|`canvas` +|{kib-repo}blob/{branch}/x-pack/plugins/canvas/README.md[Canvas plugin API] + +|`chromeNavControls` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.md[`core.chrome.navControls.register{Left,Right}`] + +|`docViews` +|{kib-repo}blob/{branch}/src/plugins/discover/public/[`discover.docViews.addDocView`] + +|`embeddableActions` +|{kib-repo}blob/{branch}/src/plugins/embeddable/README.asciidoc[`embeddable plugin`] + +|`embeddableFactories` +|{kib-repo}blob/{branch}/src/plugins/embeddable/README.asciidoc[`embeddable plugin`], {kib-repo}blob/{branch}/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.registerembeddablefactory.md[`embeddable.registerEmbeddableFactory`] + +|`fieldFormatEditors`, `fieldFormats` +|{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.fieldformats.md[`data.fieldFormats`] + +|`hacks` +|`N/A`. Just run the code in your plugin’s `start` method. + +|`home` +|{kib-repo}blob/{branch}/src/plugins/embeddable/README.asciidoc[`home plugin`] {kib-repo}blob/{branch}/src/plugins/home/public/services/feature_catalogue[`home.featureCatalogue.register`] + +|`indexManagement` +|{kib-repo}blob/{branch}/x-pack/plugins/index_management/README.md[`index management plugin`] + +|`injectDefaultVars` +|`N/A`. Plugins will only be able to allow config values for the frontend. See<> + +|`inspectorViews` +|{kib-repo}blob/{branch}/src/plugins/inspector/README.md[`inspector plugin`] + +|`interpreter` +|{kib-repo}blob/{branch}/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.md[`plugins.data.expressions`] + +|`links` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md[`core.application.register`] + +|`managementSections` +|{kib-repo}blob/{branch}/src/plugins/management/README.md[`plugins.management.sections.register`] + +|`mappings` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`migrations` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`navbarExtensions` +|`N/A`. Deprecated. + +|`savedObjectSchemas` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`savedObjectsManagement` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`savedObjectTypes` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`search` +|{kib-repo}blob/{branch}/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md[`data.search`] + +|`shareContextMenuExtensions` +|{kib-repo}blob/{branch}/src/plugins/share/README.md[`plugins.share`] + +|`taskDefinitions` +|{kib-repo}blob/{branch}/x-pack/plugins/task_manager/README.md[`taskManager plugin`] + +|`uiCapabilities` +|{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md[`core.application.register`] + +|`uiSettingDefaults` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.uisettingsservicesetup.md[`core.uiSettings.register`] + +|`validations` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md[`core.savedObjects.registerType`] + +|`visEditorTypes` +|{kib-repo}blob/{branch}/src/plugins/visualizations[`visualizations plugin`] + +|`visTypeEnhancers` +|{kib-repo}blob/{branch}/src/plugins/visualizations[`visualizations plugin`] + +|`visTypes` +|{kib-repo}blob/{branch}/src/plugins/visualizations[`visualizations plugin`] + +|`visualize` +|{kib-repo}blob/{branch}/src/plugins/visualize/README.md[`visualize plugin`] +|=== + +===== Plugin Spec + +[width="100%",cols="22%,78%",options="header",] +|=== +|Legacy Platform |{kib} Platform +|`id` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`manifest.id`] + +|`require` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`manifest.requiredPlugins`] + +|`version` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`manifest.version`] + +|`kibanaVersion` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`manifest.kibanaVersion`] + +|`configPrefix` +|{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md[`manifest.configPath`] + +|`config` +|<> + +|`deprecations` +|<> + +|`uiExports` +|`N/A`. Use platform & plugin public contracts + +|`publicDir` +|`N/A`. {kib} Platform serves static assets from `/public/assets` folder under `/plugins/{id}/assets/{path*}` URL. + +|`preInit`, `init`, `postInit` +|`N/A`. Use {kib} Platform <> +|=== + +=== See also + +For examples on how to migrate from specific legacy APIs, see <>. diff --git a/docs/developer/plugin/plugin-tooling.asciidoc b/docs/developer/plugin/plugin-tooling.asciidoc new file mode 100644 index 000000000000..0b33a585863a --- /dev/null +++ b/docs/developer/plugin/plugin-tooling.asciidoc @@ -0,0 +1,50 @@ +[[plugin-tooling]] +== Plugin tooling + +[discrete] +[[automatic-plugin-generator]] +=== Automatic plugin generator + +We recommend that you kick-start your plugin by generating it with the {kib-repo}tree/{branch}/packages/kbn-plugin-generator[{kib} Plugin Generator]. Run the following in the {kib} repo, and you will be asked a couple of questions, see some progress bars, and have a freshly generated plugin ready for you to play with in {kib}'s `plugins` folder. + +["source","shell"] +----------- +node scripts/generate_plugin my_plugin_name # replace "my_plugin_name" with your desired plugin name +----------- + +[discrete] +=== Plugin location + +The {kib} directory must be named `kibana`, and your plugin directory should be located in the root of `kibana` in a `plugins` directory, for example: + +["source","shell"] +---- +. +└── kibana + └── plugins + ├── foo-plugin + └── bar-plugin +---- + +=== Build plugin distributable +WARNING: {kib} distributable is not shipped with `@kbn/optimizer` anymore. You need to pre-build your plugin for use in production. + +You can leverage {kib-repo}blob/{branch}/packages/kbn-plugin-helpers[@kbn/plugin-helpers] to build a distributable archive for your plugin. +The package transpiles the plugin code, adds polyfills, and links necessary js modules in the runtime. +You don't need to install the `plugin-helpers`: the `package.json` is already pre-configured if you created your plugin with `node scripts/generate_plugin` script. +To build your plugin run within your plugin folder: +["source","shell"] +----------- +yarn build +----------- +It will output a`zip` archive in `kibana/plugins/my_plugin_name/build/` folder. + +=== Install a plugin from archive +See <>. + +=== Run {kib} with your plugin in dev mode +Run `yarn start` in the {kib} root folder. Make sure {kib} found and bootstrapped your plugin: +["source","shell"] +----------- +[info][plugins-system] Setting up […] plugins: […, myPluginName, …] +----------- diff --git a/docs/developer/plugin/testing-kibana-plugin.asciidoc b/docs/developer/plugin/testing-kibana-plugin.asciidoc new file mode 100644 index 000000000000..6e856d2e2578 --- /dev/null +++ b/docs/developer/plugin/testing-kibana-plugin.asciidoc @@ -0,0 +1,63 @@ +[[testing-kibana-plugin]] +== Testing {kib} Plugins +=== Writing tests +Learn about <>. + +=== Mock {kib} Core services in tests + +Core services already provide mocks to simplify testing and make sure +plugins always rely on valid public contracts: + +*my_plugin/server/plugin.test.ts* +[source,typescript] +---- +import { configServiceMock } from 'kibana/server/mocks'; + +const configService = configServiceMock.create(); +configService.atPath.mockReturnValue(config$); +… +const plugin = new MyPlugin({ configService }, …); +---- + +Or if you need to get the whole core `setup` or `start` contracts: + +*my_plugin/server/plugin.test.ts* +[source,typescript] +---- +import { coreMock } from 'kibana/public/mocks'; + +const coreSetup = coreMock.createSetup(); +coreSetup.uiSettings.get.mockImplementation((key: string) => { + … +}); +… +const plugin = new MyPlugin(coreSetup, ...); +---- + +=== Writing mocks for your plugin +Although it isn’t mandatory, we strongly recommended you export your +plugin mocks as well, in order for dependent plugins to use them in +tests. Your plugin mocks should be exported from the root `/server` and +`/public` directories in your plugin: + +*my_plugin/(server|public)/mocks.ts* +[source,typescript] +---- +const createSetupContractMock = () => { + const startContract: jest.Mocked= { + isValid: jest.fn(), + } + // here we already type check as TS infers to the correct type declared above + startContract.isValid.mockReturnValue(true); + return startContract; +} + +export const myPluginMocks = { + createSetup: createSetupContractMock, + createStart: … +} +---- + +Plugin mocks should consist of mocks for _public APIs only_: +`setup`, `start` & `stop` contracts. Mocks aren’t necessary for pure functions as +other plugins can call the original implementation in tests. diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md deleted file mode 100644 index 49b962670220..000000000000 --- a/src/core/MIGRATION.md +++ /dev/null @@ -1,1774 +0,0 @@ -# Migrating legacy plugins to the new platform - -- [Migrating legacy plugins to the new platform](#migrating-legacy-plugins-to-the-new-platform) - - [Overview](#overview) - - [Architecture](#architecture) - - [Services](#services) - - [Integrating with other plugins](#integrating-with-other-plugins) - - [Challenges to overcome with legacy plugins](#challenges-to-overcome-with-legacy-plugins) - - [Challenges on the server](#challenges-on-the-server) - - [Challenges in the browser](#challenges-in-the-browser) - - [Plan of action](#plan-of-action) - - [Server-side plan of action](#server-side-plan-of-action) - - [De-couple from hapi.js server and request objects](#de-couple-from-hapijs-server-and-request-objects) - - [Introduce new plugin definition shim](#introduce-new-plugin-definition-shim) - - [Switch to new platform services](#switch-to-new-platform-services) - - [Migrate to the new plugin system](#migrate-to-the-new-plugin-system) - - [Browser-side plan of action](#browser-side-plan-of-action) - - [1. Create a plugin definition file](#1-create-a-plugin-definition-file) - - [2. Export all static code and types from `public/index.ts`](#2-export-all-static-code-and-types-from-publicindexts) - - [3. Export your runtime contract](#3-export-your-runtime-contract) - - [4. Move "owned" UI modules into your plugin and expose them from your public contract](#4-move-owned-ui-modules-into-your-plugin-and-expose-them-from-your-public-contract) - - [5. Provide plugin extension points decoupled from angular.js](#5-provide-plugin-extension-points-decoupled-from-angularjs) - - [6. Move all webpack alias imports into uiExport entry files](#6-move-all-webpack-alias-imports-into-uiexport-entry-files) - - [7. Switch to new platform services](#7-switch-to-new-platform-services) - - [8. Migrate to the new plugin system](#8-migrate-to-the-new-plugin-system) - - [Bonus: Tips for complex migration scenarios](#bonus-tips-for-complex-migration-scenarios) - - [Keep Kibana fast](#keep-kibana-fast) - - [Frequently asked questions](#frequently-asked-questions) - - [Is migrating a plugin an all-or-nothing thing?](#is-migrating-a-plugin-an-all-or-nothing-thing) - - [Do plugins need to be converted to TypeScript?](#do-plugins-need-to-be-converted-to-typescript) - - [Can static code be shared between plugins?](#can-static-code-be-shared-between-plugins) - - [Background](#background) - - [What goes wrong if I do share modules with state?](#what-goes-wrong-if-i-do-share-modules-with-state) - - [How to decide what code can be statically imported](#how-to-decide-what-code-can-be-statically-imported) - - [Concrete Example](#concrete-example) - - [How can I avoid passing Core services deeply within my UI component tree?](#how-can-i-avoid-passing-core-services-deeply-within-my-ui-component-tree) - - [How is "common" code shared on both the client and server?](#how-is-common-code-shared-on-both-the-client-and-server) - - [When does code go into a plugin, core, or packages?](#when-does-code-go-into-a-plugin-core-or-packages) - - [How do I build my shim for New Platform services?](#how-do-i-build-my-shim-for-new-platform-services) - - [Client-side](#client-side) - - [Core services](#core-services) - - [Plugins for shared application services](#plugins-for-shared-application-services) - - [Server-side](#server-side) - - [Core services](#core-services-1) - - [Plugin services](#plugin-services) - - [UI Exports](#ui-exports) - - [Plugin Spec](#plugin-spec) - - [How to](#how-to) - - [Configure plugin](#configure-plugin) - - [Handle plugin configuration deprecations](#handle-plugin-configuration-deprecations) - - [Use scoped services](#use-scoped-services) - - [Declare a custom scoped service](#declare-a-custom-scoped-service) - - [Mock new platform services in tests](#mock-new-platform-services-in-tests) - - [Writing mocks for your plugin](#writing-mocks-for-your-plugin) - - [Using mocks in your tests](#using-mocks-in-your-tests) - - [What about karma tests?](#what-about-karma-tests) - - [Provide Legacy Platform API to the New platform plugin](#provide-legacy-platform-api-to-the-new-platform-plugin) - - [On the server side](#on-the-server-side) - - [On the client side](#on-the-client-side) - - [Updates an application navlink at runtime](#updates-an-application-navlink-at-runtime) - - [Logging config migration](#logging-config-migration) - - [Use HashRouter in migrated apps](#use-react-hashrouter-in-migrated-apps) - -Make no mistake, it is going to take a lot of work to move certain plugins to the new platform. Our target is to migrate the entire repo over to the new platform throughout 7.x and to remove the legacy plugin system no later than 8.0, and this is only possible if teams start on the effort now. - -The goal of this document is to guide teams through the recommended process of migrating at a high level. Every plugin is different, so teams should tweak this plan based on their unique requirements. - -We'll start with an overview of how plugins work in the new platform, and we'll end with a generic plan of action that can be applied to any plugin in the repo today. - -## Overview - -Plugins in the new platform are not especially novel or complicated to describe. Our intention wasn't to build some clever system that magically solved problems through abstractions and layers of obscurity, and we wanted to make sure plugins could continue to use most of the same technologies they use today, at least from a technical perspective. - -New platform plugins exist in the `src/plugins` and `x-pack/plugins` directories. _See all [conventions for first-party Elastic plugins](./CONVENTIONS.md)_. - -### Architecture - -Plugins are defined as classes and exposed to the platform itself through a simple wrapper function. A plugin can have browser side code, server side code, or both. There is no architectural difference between a plugin in the browser and a plugin on the server, which is to say that in both places you describe your plugin similarly, and you interact with core and/or other plugins in the same way. - -The basic file structure of a new platform plugin named "demo" that had both client-side and server-side code would be: - -```tree -src/plugins - demo - kibana.json [1] - public - index.ts [2] - plugin.ts [3] - server - index.ts [4] - plugin.ts [5] -``` - -**[1] `kibana.json`** is a [static manifest](../../docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) file that is used to identify the plugin and to determine what kind of code the platform should execute from the plugin: - -```json -{ - "id": "demo", - "version": "kibana", - "server": true, - "ui": true -} -``` - -More details about[manifest file format](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) - -Note that `package.json` files are irrelevant to and ignored by the new platform. - -**[2] `public/index.ts`** is the entry point into the client-side code of this plugin. It must export a function named `plugin`, which will receive a standard set of core capabilities as an argument (e.g. logger). It should return an instance of its plugin definition for the platform to register at load time. - -```ts -import { PluginInitializerContext } from 'kibana/server'; -import { Plugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new Plugin(initializerContext); -} -``` - -**[3] `public/plugin.ts`** is the client-side plugin definition itself. Technically speaking it does not need to be a class or even a separate file from the entry point, but _all plugins at Elastic_ should be consistent in this way. _See all [conventions for first-party Elastic plugins](./CONVENTIONS.md)_. - -```ts -import { PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; - -export class Plugin { - constructor(initializerContext: PluginInitializerContext) {} - - public setup(core: CoreSetup) { - // called when plugin is setting up - } - - public start(core: CoreStart) { - // called after all plugins are set up - } - - public stop() { - // called when plugin is torn down, aka window.onbeforeunload - } -} -``` - -**[4] `server/index.ts`** is the entry-point into the server-side code of this plugin. It is identical in almost every way to the client-side entry-point: - -```ts -import { PluginInitializerContext } from 'kibana/server'; -import { Plugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new Plugin(initializerContext); -} -``` - -**[5] `server/plugin.ts`** is the server-side plugin definition. The _shape_ of this plugin is the same as it's client-side counter-part: - -```ts -import { PluginInitializerContext, CoreSetup, CoreStart } from 'kibana/server'; - -export class Plugin { - constructor(initializerContext: PluginInitializerContext) {} - - public setup(core: CoreSetup) { - // called when plugin is setting up during Kibana's startup sequence - } - - public start(core: CoreStart) { - // called after all plugins are set up - } - - public stop() { - // called when plugin is torn down during Kibana's shutdown sequence - } -} -``` - -The platform does not impose any technical restrictions on how the internals of the plugin are architected, though there are certain considerations related to how plugins interact with core and how plugins interact with other plugins that may greatly impact how they are built. - -### Services - -The various independent domains that make up `core` are represented by a series of services, and many of those services expose public interfaces that are provided to _all_ plugins. Services expose different features at different parts of their _lifecycle_. We describe the lifecycle of core services and plugins with specifically-named functions on the service definition. - -In the new platform, there are three lifecycle functions today: `setup`, `start`, and `stop`. The `setup` functions are invoked sequentially while Kibana is setting up on the server or when it is being loaded in the browser. The `start` functions are invoked sequentially after setup has completed for all plugins. The `stop` functions are invoked sequentially while Kibana is gracefully shutting down on the server or when the browser tab or window is being closed. - -The table below explains how each lifecycle event relates to the state of Kibana. - -| lifecycle event | server | browser | -| --------------- | ----------------------------------------- | --------------------------------------------------- | -| *setup* | bootstrapping and configuring routes | loading plugin bundles and configuring applications | -| *start* | server is now serving traffic | browser is now showing UI to the user | -| *stop* | server has received a request to shutdown | user is navigating away from Kibana | - -There is no equivalent behavior to `start` or `stop` in legacy plugins, so this guide primarily focuses on migrating functionality into `setup`. - -The lifecycle-specific contracts exposed by core services are always passed as the first argument to the equivalent lifecycle function in a plugin. For example, the core `UiSettings` service exposes a function `get` to all plugin `setup` functions. To use this function to retrieve a specific UI setting, a plugin just accesses it off of the first argument: - -```ts -import { CoreSetup } from 'kibana/server'; - -export class Plugin { - public setup(core: CoreSetup) { - core.uiSettings.get('courier:maxShardsBeforeCryTime'); - } -} -``` - -Different service interfaces can and will be passed to `setup` and `stop` because certain functionality makes sense in the context of a running plugin while other types of functionality may have restrictions or may only make sense in the context of a plugin that is stopping. - -For example, the `stop` function in the browser gets invoked as part of the `window.onbeforeunload` event, which means you can't necessarily execute asynchronous code here in a reliable way. For that reason, `core` likely wouldn't provide any asynchronous functions to plugin `stop` functions in the browser. - -Core services that expose functionality to plugins always have their `setup` function ran before any plugins. - -These are the contracts exposed by the core services for each lifecycle event: - -| lifecycle event | contract | -| --------------- | --------------------------------------------------------------------------------------------------------------- | -| *contructor* | [PluginInitializerContext](../../docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md) | -| *setup* | [CoreSetup](../../docs/development/core/server/kibana-plugin-core-server.coresetup.md) | -| *start* | [CoreStart](../../docs/development/core/server/kibana-plugin-core-server.corestart.md) | -| *stop* | | - -### Integrating with other plugins - -Plugins can expose public interfaces for other plugins to consume. Like `core`, those interfaces are bound to the lifecycle functions `setup` and/or `start`. - -Anything returned from `setup` or `start` will act as the interface, and while not a technical requirement, all first-party Elastic plugins should expose types for that interface as well. 3rd party plugins wishing to allow other plugins to integrate with it are also highly encouraged to expose types for their plugin interfaces. - -**foobar plugin.ts:** - -```ts -export type FoobarPluginSetup = ReturnType; -export type FoobarPluginStart = ReturnType; - -export class Plugin { - public setup() { - return { - getFoo() { - return 'foo'; - }, - }; - } - - public start() { - return { - getBar() { - return 'bar'; - }, - }; - } -} -``` - -Unlike core, capabilities exposed by plugins are _not_ automatically injected into all plugins. Instead, if a plugin wishes to use the public interface provided by another plugin, they must first declare that plugin as a dependency in their `kibana.json`. - -**demo kibana.json:** - -```json -{ - "id": "demo", - "requiredPlugins": ["foobar"], - "server": true, - "ui": true -} -``` - -With that specified in the plugin manifest, the appropriate interfaces are then available via the second argument of `setup` and/or `start`: - -**demo plugin.ts:** - -```ts -import { CoreSetup, CoreStart } from 'src/core/server'; -import { FoobarPluginSetup, FoobarPluginStop } from '../../foobar/server'; - -interface DemoSetupPlugins { - foobar: FoobarPluginSetup; -} - -interface DemoStartPlugins { - foobar: FoobarPluginStart; -} - -export class Plugin { - public setup(core: CoreSetup, plugins: DemoSetupPlugins) { - const { foobar } = plugins; - foobar.getFoo(); // 'foo' - foobar.getBar(); // throws because getBar does not exist - } - - public start(core: CoreStart, plugins: DemoStartPlugins) { - const { foobar } = plugins; - foobar.getFoo(); // throws because getFoo does not exist - foobar.getBar(); // 'bar' - } - - public stop() {}, -} -``` - -### Challenges to overcome with legacy plugins - -New platform plugins have identical architecture in the browser and on the server. Legacy plugins have one architecture that they use in the browser and an entirely different architecture that they use on the server. - -This means that there are unique sets of challenges for migrating to the new platform depending on whether the legacy plugin code is on the server or in the browser. - -#### Challenges on the server - -The general shape/architecture of legacy server-side code is similar to the new platform architecture in one important way: most legacy server-side plugins define an `init` function where the bulk of their business logic begins, and they access both "core" and "plugin-provided" functionality through the arguments given to `init`. Rarely does legacy server-side code share stateful services via import statements. - -While not exactly the same, legacy plugin `init` functions behave similarly today as new platform `setup` functions. `KbnServer` also exposes an `afterPluginsInit` method which behaves similarly to `start`. There is no corresponding legacy concept of `stop`, however. - -Despite their similarities, server-side plugins pose a formidable challenge: legacy core and plugin functionality is retrieved from either the hapi.js `server` or `request` god objects. Worse, these objects are often passed deeply throughout entire plugins, which directly couples business logic with hapi. And the worst of it all is, these objects are mutable at any time. - -The key challenge to overcome with legacy server-side plugins will decoupling from hapi. - -#### Challenges in the browser - -The legacy plugin system in the browser is fundamentally incompatible with the new platform. There is no client-side plugin definition. There are no services that get passed to plugins at runtime. There really isn't even a concrete notion of "core". - -When a legacy browser plugin needs to access functionality from another plugin, say to register a UI section to render within another plugin, it imports a stateful (global singleton) JavaScript module and performs some sort of state mutation. Sometimes this module exists inside the plugin itself, and it gets imported via the `plugin/` webpack alias. Sometimes this module exists outside the context of plugins entirely and gets imported via the `ui/` webpack alias. Neither of these concepts exist in the new platform. - -Legacy browser plugins rely on the feature known as `uiExports/`, which integrates directly with our build system to ensure that plugin code is bundled together in such a way to enable that global singleton module state. There is no corresponding feature in the new platform, and in fact we intend down the line to build new platform plugins as immutable bundles that can not share state in this way. - -The key challenge to overcome with legacy browser-side plugins will be converting all imports from `plugin/`, `ui/`, `uiExports`, and relative imports from other plugins into a set of services that originate at runtime during plugin initialization and get passed around throughout the business logic of the plugin as function arguments. - -### Plan of action - -In order to move a legacy plugin to the new plugin system, the challenges on the server and in the browser must be addressed. Fortunately, **the hardest problems can be solved in legacy plugins today** without consuming the new plugin system at all. - -The approach and level of effort varies significantly between server and browser plugins, but at a high level the approach is the same. - -First, decouple your plugin's business logic from the dependencies that are not exposed through the new platform, hapi.js and angular.js. Then introduce plugin definitions that more accurately reflect how plugins are defined in the new platform. Finally, replace the functionality you consume from core and other plugins with their new platform equivalents. - -Once those things are finished for any given plugin, it can officially be switched to the new plugin system. - -## Server-side plan of action - -Legacy server-side plugins access functionality from core and other plugins at runtime via function arguments, which is similar to how they must be architected to use the new plugin system. This greatly simplifies the plan of action for migrating server-side plugins. - -Here is the high-level for migrating a server-side plugin: - -- De-couple from hapi.js server and request objects -- Introduce a new plugin definition shim -- Replace legacy services in shim with new platform services -- Finally, move to the new plugin system - -These steps (except for the last one) do not have to be completed strictly in order, and some can be done in parallel or as part of the same change. In general, we recommend that larger plugins approach this more methodically, doing each step in a separate change. This makes each individual change less risk and more focused. This approach may not make sense for smaller plugins. For instance, it may be simpler to switch to New Platform services when you introduce your Plugin class, rather than shimming it with the legacy service. - -### De-couple from hapi.js server and request objects - -Most integrations with core and other plugins occur through the hapi.js `server` and `request` objects, and neither of these things are exposed through the new platform, so tackle this problem first. - -Fortunately, decoupling from these objects is relatively straightforward. - -The server object is introduced to your plugin in its legacy `init` function, so in that function you will "pick" the functionality you actually use from `server` and attach it to a new interface, which you will then pass in all the places you had previously been passing `server`. - -The `request` object is introduced to your plugin in every route handler, so at the root of every route handler, you will create a new interface by "picking" the request information (e.g. body, headers) and core and plugin capabilities from the `request` object that you actually use and pass that in all the places you previously were passing `request`. - -Any calls to mutate either the server or request objects (e.g. `server.decorate()`) will be moved toward the root of the legacy `init` function if they aren't already there. - -Let's take a look at an example legacy plugin definition that uses both `server` and `request`. - -```ts -// likely imported from another file -function search(server, request) { - const { elasticsearch } = server.plugins; - return elasticsearch.getCluster('admin').callWithRequest(request, 'search'); -} - -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - server.route({ - path: '/api/demo_plugin/search', - method: 'POST', - async handler(request) { - search(server, request); // target acquired - }, - }); - - server.expose('getDemoBar', () => { - return `Demo ${server.plugins.foo.getBar()}`; - }); - }, - }); -}; -``` - -This example legacy plugin uses hapi's `server` object directly inside of its `init` function, which is something we can address in a later step. What we need to address in this step is when we pass the raw `server` and `request` objects into our custom `search` function. - -Our goal in this step is to make sure we're not integrating with other plugins via functions on `server.plugins.*` or on the `request` object. You should begin by finding all of the integration points where you make these calls, and put them behind a "facade" abstraction that can hide the details of where these APIs come from. This allows you to easily switch out how you access these APIs without having to change all of the code that may use them. - -Instead, we identify which functionality we actually need from those objects and craft custom new interfaces for them, taking care not to leak hapi.js implementation details into their design. - -```ts -import { ElasticsearchPlugin, Request } from '../elasticsearch'; -export interface ServerFacade { - plugins: { - elasticsearch: ElasticsearchPlugin; - }; -} -export interface RequestFacade extends Request {} - -// likely imported from another file -function search(server: ServerFacade, request: RequestFacade) { - const { elasticsearch } = server.plugins; - return elasticsearch.getCluster('admin').callWithRequest(request, 'search'); -} - -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - const serverFacade: ServerFacade = { - plugins: { - elasticsearch: server.plugins.elasticsearch, - }, - }; - - server.route({ - path: '/api/demo_plugin/search', - method: 'POST', - async handler(request) { - const requestFacade: RequestFacade = { - headers: request.headers, - }; - search(serverFacade, requestFacade); - }, - }); - - server.expose('getDemoBar', () => { - return `Demo ${server.plugins.foo.getBar()}`; - }); - }, - }); -}; -``` - -This change might seem trivial, but it's important for two reasons. - -First, the business logic built into `search` is now coupled to an object you created manually and have complete control over rather than hapi itself. This will allow us in a future step to replace the dependency on hapi without necessarily having to modify the business logic of the plugin. - -Second, it forced you to clearly define the dependencies you have on capabilities provided by core and by other plugins. This will help in a future step when you must replace those capabilities with services provided through the new platform. - -### Introduce new plugin definition shim - -While most plugin logic is now decoupled from hapi, the plugin definition itself still uses hapi to expose functionality for other plugins to consume and access functionality from both core and a different plugin. - -```ts -// index.ts - -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - const serverFacade: ServerFacade = { - plugins: { - elasticsearch: server.plugins.elasticsearch, - }, - }; - - // HTTP functionality from legacy - server.route({ - path: '/api/demo_plugin/search', - method: 'POST', - async handler(request) { - const requestFacade: RequestFacade = { - headers: request.headers, - }; - search(serverFacade, requestFacade); - }, - }); - - // Exposing functionality for other plugins - server.expose('getDemoBar', () => { - return `Demo ${server.plugins.foo.getBar()}`; // Accessing functionality from another plugin - }); - }, - }); -}; -``` - -We now move this logic into a new plugin definition, which is based off of the conventions used in real new platform plugins. While the legacy plugin definition is in the root of the plugin, this new plugin definition will be under the plugin's `server/` directory since it is only the server-side plugin definition. - -```ts -// server/plugin.ts -import { CoreSetup, Plugin } from 'src/core/server'; -import { ElasticsearchPlugin } from '../elasticsearch'; - -interface FooSetup { - getBar(): string; -} - -// We inject the miminal legacy dependencies into our plugin including dependencies on other legacy -// plugins. Take care to only expose the legacy functionality you need e.g. don't inject the whole -// `Legacy.Server` if you only depend on `Legacy.Server['route']`. -interface LegacySetup { - route: Legacy.Server['route']; - plugins: { - elasticsearch: ElasticsearchPlugin; // note: Elasticsearch is in CoreSetup in NP, rather than a plugin - foo: FooSetup; - }; -} - -// Define the public API's for our plugins setup and start lifecycle -export interface DemoSetup { - getDemoBar: () => string; -} -export interface DemoStart {} - -// Once we start dependending on NP plugins' setup or start API's we'll add their types here -export interface DemoSetupDeps {} -export interface DemoStartDeps {} - -export class DemoPlugin implements Plugin { - public setup(core: CoreSetup, plugins: PluginsSetup, __LEGACY: LegacySetup): DemoSetup { - // We're still using the legacy Elasticsearch and http router here, but we're now accessing - // these services in the same way a NP plugin would: injected into the setup function. It's - // also obvious that these dependencies needs to be removed by migrating over to the New - // Platform services exposed through core. - const serverFacade: ServerFacade = { - plugins: { - elasticsearch: __LEGACY.plugins.elasticsearch, - }, - }; - - __LEGACY.route({ - path: '/api/demo_plugin/search', - method: 'POST', - async handler(request) { - const requestFacade: RequestFacade = { - headers: request.headers, - }; - search(serverFacade, requestFacade); - }, - }); - - // Exposing functionality for other plugins - return { - getDemoBar() { - return `Demo ${__LEGACY.plugins.foo.getBar()}`; // Accessing functionality from another legacy plugin - }, - }; - } -} -``` - -The legacy plugin definition is still the one that is being executed, so we now "shim" this new plugin definition into the legacy world by instantiating it and wiring it up inside of the legacy `init` function. - -```ts -// index.ts - -import { Plugin, PluginDependencies, LegacySetup } from './server/plugin'; - -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - // core setup API's - const coreSetup = server.newPlatform.setup.core; - - // For now we don't have any dependencies on NP plugins - const pluginsSetup: PluginsSetup = {}; - - // legacy dependencies - const __LEGACY: LegacySetup = { - route: server.route, - plugins: { - elasticsearch: server.plugins.elasticsearch, - foo: server.plugins.foo, - }, - }; - - const demoSetup = new Plugin().setup(coreSetup, pluginsSetup, __LEGACY); - - // continue to expose functionality to legacy plugins - server.expose('getDemoBar', demoSetup.getDemoBar); - }, - }); -}; -``` - -> Note: An equally valid approach is to extend `CoreSetup` with a `__legacy` -> property instead of introducing a third parameter to your plugins lifecycle -> function. The important thing is that you reduce the legacy API surface that -> you depend on to a minimum by only picking and injecting the methods you -> require and that you clearly differentiate legacy dependencies in a namespace. - -This introduces a layer between the legacy plugin system with hapi.js and the logic you want to move to the new plugin system. The functionality exposed through that layer is still provided from the legacy world and in some cases is still technically powered directly by hapi, but building this layer forced you to identify the remaining touch points into the legacy world and it provides you with control when you start migrating to new platform-backed services. - -> Need help constructing your shim? There are some common APIs that are already present in the New Platform. In these cases, it may make more sense to simply use the New Platform service rather than crafting your own shim. Refer to the _[How do I build my shim for New Platform services?](#how-do-i-build-my-shim-for-new-platform-services)_ section for a table of legacy to new platform service translations to identify these. Note that while some APIs have simply _moved_ others are completely different. Take care when choosing how much refactoring to do in a single change. - -### Switch to new platform services - -At this point, your legacy server-side plugin is described in the shape and -conventions of the new plugin system, and all of the touch points with the -legacy world and hapi.js have been isolated inside the `__LEGACY` parameter. - -Now the goal is to replace all legacy services with services provided by the new platform instead. - -For the first time in this guide, your progress here is limited by the migration efforts within core and other plugins. - -As core capabilities are migrated to services in the new platform, they are made available as lifecycle contracts to the legacy `init` function through `server.newPlatform`. This allows you to adopt the new platform service APIs directly in your legacy plugin as they get rolled out. - -For the most part, care has been taken when migrating services to the new platform to preserve the existing APIs as much as possible, but there will be times when new APIs differ from the legacy equivalents. - -If a legacy API differs from its new platform equivalent, some refactoring will be required. The best outcome comes from updating the plugin code to use the new API, but if that's not practical now, you can also create a facade inside your new plugin definition that is shaped like the legacy API but powered by the new API. Once either of these things is done, that override can be removed from the shim. - -Eventually, all `__LEGACY` dependencies will be removed and your Plugin will -be powered entirely by Core API's from `server.newPlatform.setup.core`. - -```ts -init(server) { - // core setup API's - const coreSetup = server.newPlatform.setup.core; - - // For now we don't have any dependencies on NP plugins - const pluginsSetup: PluginsSetup = {}; - - // legacy dependencies, we've removed our dependency on elasticsearch and server.route - const __LEGACY: LegacySetup = { - plugins: { - foo: server.plugins.foo - } - }; - - const demoSetup = new Plugin().setup(coreSetup, pluginsSetup, __LEGACY); -} -``` - -At this point, your legacy server-side plugin logic is no longer coupled to -the legacy core. - -A similar approach can be taken for your plugin dependencies. To start -consuming an API from a New Platform plugin access these from -`server.newPlatform.setup.plugins` and inject it into your plugin's setup -function. - -```ts -init(server) { - // core setup API's - const coreSetup = server.newPlatform.setup.core; - - // Depend on the NP plugin 'foo' - const pluginsSetup: PluginsSetup = { - foo: server.newPlatform.setup.plugins.foo - }; - - const demoSetup = new Plugin().setup(coreSetup, pluginsSetup); -} -``` - -As the plugins you depend on are migrated to the new platform, their contract -will be exposed through `server.newPlatform`, so the `__LEGACY` dependencies -should be removed. Like in core, plugins should take care to preserve their -existing APIs to make this step as seamless as possible. - -It is much easier to reliably make breaking changes to plugin APIs in the new -platform than it is in the legacy world, so if you're planning a big change, -consider doing it after your dependent plugins have migrated rather than as -part of your own migration. - -Eventually, all `__LEGACY` dependencies will be removed and your plugin will be -entirely powered by the New Platform and New Platform plugins. - -> Note: All New Platform plugins are exposed to legacy plugins via -> `server.newPlatform.setup.plugins`. Once you move your plugin over to the -> New Platform you will have to explicitly declare your dependencies on other -> plugins in your `kibana.json` manifest file. - -At this point, your legacy server-side plugin logic is no longer coupled to legacy plugins. - -### Migrate to the new plugin system - -With both shims converted, you are now ready to complete your migration to the new platform. - -Many plugins will copy and paste all of their plugin code into a new plugin directory in either `src/plugins` for OSS or `x-pack/plugins` for commerical code and then delete their legacy shims. It's at this point that you'll want to make sure to create your `kibana.json` file if it does not already exist. - -With the previous steps resolved, this final step should be easy, but the exact process may vary plugin by plugin, so when you're at this point talk to the platform team to figure out the exact changes you need. - -Other plugins may want to move subsystems over individually. For instance, you can move routes over to the New Platform in groups rather than all at once. Other examples that could be broken up: - -- Configuration schema ([see example](./MIGRATION_EXAMPLES.md#declaring-config-schema)) -- HTTP route registration ([see example](./MIGRATION_EXAMPLES.md#http-routes)) -- Polling mechanisms (eg. job worker) - -In general, we recommend moving all at once by ensuring you're not depending on any legacy code before you move over. - -## Browser-side plan of action - -It is generally a much greater challenge preparing legacy browser-side code for the new platform than it is server-side, and as such there are a few more steps. The level of effort here is proportional to the extent to which a plugin is dependent on angular.js. - -To complicate matters further, a significant amount of the business logic in Kibana's client-side code exists inside the `ui/public` directory (aka ui modules), and all of that must be migrated as well. Unlike the server-side code where the order in which you migrated plugins was not particularly important, it's important that UI modules be addressed as soon as possible. - -Because usage of angular and `ui/public` modules varies widely between legacy plugins, there is no "one size fits all" solution to migrating your browser-side code to the new platform. The best place to start is by checking with the platform team to help identify the best migration path for your particular plugin. - -That said, we've seen a series of patterns emerge as teams begin migrating browser code. In practice, most migrations will follow a path that looks something like this: - -#### 1. Create a plugin definition file - -We've found that doing this right away helps you start thinking about your plugin in terms of lifecycle methods and services, which makes the rest of the migration process feel more natural. It also forces you to identify which actions "kick off" your plugin, since you'll need to execute those when the `setup/start` methods are called. - -This definition isn't going to do much for us just yet, but as we get further into the process, we will gradually start returning contracts from our `setup` and `start` methods, while also injecting dependencies as arguments to these methods. - -```ts -// public/plugin.ts -import { CoreSetup, CoreStart, Plugin } from 'kibana/server'; -import { FooSetup, FooStart } from '../../../../legacy/core_plugins/foo/public'; - -/** - * These are the private interfaces for the services your plugin depends on. - * @internal - */ -export interface DemoSetupDeps { - foo: FooSetup; -} -export interface DemoStartDeps { - foo: FooStart; -} - -/** - * These are the interfaces with your public contracts. You should export these - * for other plugins to use in _their_ `SetupDeps`/`StartDeps` interfaces. - * @public - */ -export type DemoSetup = {}; -export type DemoStart = {}; - -/** @internal */ -export class DemoPlugin implements Plugin { - public setup(core: CoreSetup, plugins: DemoSetupDeps): DemoSetup { - // kick off your plugin here... - return { - fetchConfig: () => ({}), - }; - } - - public start(core: CoreStart, plugins: DemoStartDeps): DemoStart { - // ...or here - return { - initDemo: () => ({}), - }; - } - - public stop() {} -} -``` - -#### 2. Export all static code and types from `public/index.ts` - -If your plugin needs to share static code with other plugins, this code must be exported from your top-level `public/index.ts`. This includes any type interfaces that you wish to make public. For details on the types of code that you can safely share outside of the runtime lifecycle contracts, see [Can static code be shared between plugins?](#can-static-code-be-shared-between-plugins) - -```ts -// public/index.ts -import { DemoSetup, DemoStart } from './plugin'; - -const myPureFn = (x: number): number => x + 1; -const MyReactComponent = (props) => { - return

Hello, {props.name}

; -}; - -// These are your public types & static code -export { myPureFn, MyReactComponent, DemoSetup, DemoStart }; -``` - -While you're at it, you can also add your plugin initializer to this file: - -```ts -// public/index.ts -import { PluginInitializer, PluginInitializerContext } from 'kibana/server'; -import { DemoSetup, DemoStart, DemoSetupDeps, DemoStartDeps, DemoPlugin } from './plugin'; - -// Core will be looking for this when loading our plugin in the new platform -export const plugin: PluginInitializer = ( - initializerContext: PluginInitializerContext -) => { - return new DemoPlugin(); -}; - -const myPureFn = (x: number): number => x + 1; -const MyReactComponent = (props) => { - return

Hello, {props.name}

; -}; - -/** @public */ -export { myPureFn, MyReactComponent, DemoSetup, DemoStart }; -``` - -Great! So you have your plugin definition, and you've moved all of your static exports to the top level of your plugin... now let's move on to the runtime contract your plugin will be exposing. - -#### 3. Export your runtime contract - -Next, we need a way to expose your runtime dependencies. In the new platform, core will handle this for you. But while we are still in the legacy world, other plugins will need a way to consume your plugin's contract without the help of core. - -So we will take a similar approach to what was described above in the server section: actually call the `Plugin.setup()` and `Plugin.start()` methods, and export the values those return for other legacy plugins to consume. By convention, we've been placing this in a `legacy.ts` file, which also serves as our shim where we import our legacy dependencies and reshape them into what we are expecting in the new platform: - -```ts -// public/legacy.ts -import { PluginInitializerContext } from 'kibana/server'; -import { npSetup, npStart } from 'ui/new_platform'; -import { plugin } from '.'; - -import { setup as fooSetup, start as fooStart } from '../../foo/public/legacy'; // assumes `foo` lives in `legacy/core_plugins` - -const pluginInstance = plugin({} as PluginInitializerContext); -const __LEGACYSetup = { - bar: {}, // shim for a core service that hasn't migrated yet - foo: fooSetup, // dependency on a legacy plugin -}; -const __LEGACYStart = { - bar: {}, // shim for a core service that hasn't migrated yet - foo: fooStart, // dependency on a legacy plugin -}; - -export const setup = pluginInstance.setup(npSetup.core, npSetup.plugins, __LEGACYSetup); -export const start = pluginInstance.start(npStart.core, npStart.plugins, __LEGACYStart); -``` - -> As you build your shims, you may be wondering where you will find some legacy services in the new platform. Skip to [the tables below](#how-do-i-build-my-shim-for-new-platform-services) for a list of some of the more common legacy services and where we currently expect them to live. - -Notice how in the example above, we are importing the `setup` and `start` contracts from the legacy shim provided by `foo` plugin; we could just as easily be importing modules from `ui/public` here as well. - -The point is that, over time, this becomes the one file in our plugin containing stateful imports from the legacy world. And _that_ is where things start to get interesting... - -#### 4. Move "owned" UI modules into your plugin and expose them from your public contract - -Everything inside of the `ui/public` directory is going to be dealt with in one of the following ways: - -- Deleted because it doesn't need to be used anymore -- Moved to or replaced by something in core that isn't coupled to angular -- Moved to or replaced by an extension point in a specific plugin that "owns" that functionality -- Copied into each plugin that depends on it and becomes an implementation detail there - -To rapidly define ownership and determine interdependencies, UI modules should move to the most appropriate plugins to own them. Modules that are considered "core" can remain in the ui directory as the platform team works to move them out. - -Concerns around ownership or duplication of a given module should be raised and resolved with the appropriate team so that the code is either duplicated to break the interdependency or a team agrees to "own" that extension point in one of their plugins and the module moves there. - -A great outcome is a module being deleted altogether because it isn't used or it was used so lightly that it was easy to refactor away. - -If it is determined that your plugin is going to own any UI modules that other plugins depend on, you'll want to migrate these quickly so that there's time for downstream plugins to update their imports. This will ultimately involve moving the module code into your plugin, and exposing it via your setup/start contracts, or as static code from your `plugin/index.ts`. We have identified owners for most of the legacy UI modules; if you aren't sure where you should move something that you own, please consult with the platform team. - -Depending on the module's level of complexity and the number of other places in Kibana that rely on it, there are a number of strategies you could use for this: - -- **Do it all at once.** Move the code, expose it from your plugin, and update all imports across Kibana. - - This works best for small pieces of code that aren't widely used. -- **Shim first, move later.** Expose the code from your plugin by importing it in your shim and then re-exporting it from your plugin first, then gradually update imports to pull from the new location, leaving the actual moving of the code as a final step. - - This works best for the largest, most widely used modules that would otherwise result in huge, hard-to-review PRs. - - It makes things easier by splitting the process into small, incremental PRs, but is probably overkill for things with a small surface area. -- **Hybrid approach.** As a middle ground, you can also move the code to your plugin immediately, and then re-export your plugin code from the original `ui/public` directory. - - This eliminates any concerns about backwards compatibility by allowing you to update the imports across Kibana later. - - Works best when the size of the PR is such that moving the code can be done without much refactoring. - -#### 5. Provide plugin extension points decoupled from angular.js - -There will be no global angular module in the new platform, which means none of the functionality provided by core will be coupled to angular. Since there is no global angular module shared by all applications, plugins providing extension points to be used by other plugins can not couple those extension points to angular either. - -All teams that own a plugin are strongly encouraged to remove angular entirely, but if nothing else they must provide non-angular-based extension points for plugins. - -One way to address this problem is to go through the code that is currently exposed to plugins and refactor away all of the touch points into angular.js. This might be the easiest option in some cases, but it might be hard in others. - -Another way to address this problem is to create an entirely new set of plugin APIs that are not dependent on angular.js, and then update the implementation within the plugin to "merge" the angular and non-angular capabilities together. This is a good approach if preserving the existing angular API until we remove the old plugin system entirely is of critical importance. Generally speaking though, the removal of angular and introduction of a new set of public plugin APIs is a good reason to make a breaking change to the existing plugin capabilities. Make sure the PRs are tagged appropriately so we add these changes to our plugin changes blog post for each release. - -Please talk with the platform team when formalizing _any_ client-side extension points that you intend to move to the new platform as there are some bundling considerations to consider. - -#### 6. Move all webpack alias imports into uiExport entry files - -Existing plugins import three things using webpack aliases today: services from ui/public (`ui/`), services from other plugins (`plugins/`), and uiExports themselves (`uiExports/`). These webpack aliases will not exist once we remove the legacy plugin system, so part of our migration effort is addressing all of the places where they are used today. - -In the new platform, dependencies from core and other plugins will be passed through lifecycle functions in the plugin definition itself. In a sense, they will be run from the "root" of the plugin. - -With the legacy plugin system, extensions of core and other plugins are handled through entry files defined as uiExport paths. In other words, when a plugin wants to serve an application (a core-owned thing), it defines a main entry file for the app via the `app` uiExport, and when a plugin wants to extend visTypes (a plugin-owned thing), they do so by specifying an entry file path for the `visType` uiExport. - -Each uiExport path is an entry file into one specific set of functionality provided by a client-side plugin. All webpack alias-based imports should be moved to these entry files, where they are appropriate. Moving a deeply nested webpack alias-based import in a plugin to one of the uiExport entry files might require some refactoring to ensure the dependency is now passed down to the appropriate place as function arguments instead of via import statements. - -For stateful dependencies using the `plugins/` and `ui/` webpack aliases, you should be able to take advantage of the `legacy.ts` shim you created earlier. By placing these imports directly in your shim, you can pass the dependencies you need into your `Plugin.start` and `Plugin.setup` methods, from which point they can be passed down to the rest of your plugin's entry files. - -For items that don't yet have a clear "home" in the new platform, it may also be helpful to somehow indicate this in your shim to make it easier to remember that you'll need to change this later. One convention we've found helpful for this is simply using a namespace like `__LEGACY`: - -```ts -// public/legacy.ts -import { uiThing } from 'ui/thing'; -... - -const pluginInstance = plugin({} as PluginInitializerContext); -const __LEGACY = { - foo: fooSetup, - uiThing, // eventually this will move out of __LEGACY and into a NP plugin -}; - -... -export const setup = pluginInstance.setup(npSetup.core, npSetup.plugins, __LEGACY); -``` - -#### 7. Switch to new platform services - -At this point, your plugin has one or more uiExport entry files that together contain all of the webpack alias-based import statements needed to run your plugin. Each one of these import statements is either a service that is or will be provided by core or a service provided by another plugin. - -As new non-angular-based APIs are added, update your entry files to import the correct service API. The service APIs provided directly from the new platform can be imported through the `ui/new_platform` module for the duration of this migration. As new services are added, they will also be exposed there. This includes all core services as well as any APIs provided by real new platform plugins. - -Once all of the existing webpack alias-based imports in your plugin switch to `ui/new_platform`, it no longer depends directly on the legacy "core" features or other legacy plugins, so it is ready to officially migrate to the new platform. - -#### 8. Migrate to the new plugin system - -With all of your services converted, you are now ready to complete your migration to the new platform. - -Many plugins at this point will copy over their plugin definition class & the code from their various service/uiExport entry files directly into the new plugin directory. The `legacy.ts` shim file can then simply be deleted. - -With the previous steps resolved, this final step should be easy, but the exact process may vary plugin by plugin, so when you're at this point talk to the platform team to figure out the exact changes you need. - -Other plugins may want to move subsystems over individually. Examples of pieces that could be broken up: - -- Registration logic (eg. viz types, embeddables, chrome nav controls) -- Application mounting -- Polling mechanisms (eg. job worker) - -#### Bonus: Tips for complex migration scenarios - -For a few plugins, some of these steps (such as angular removal) could be a months-long process. In those cases, it may be helpful from an organizational perspective to maintain a clear separation of code that is and isn't "ready" for the new platform. - -One convention that is useful for this is creating a dedicated `public/np_ready` directory to house the code that is ready to migrate, and gradually move more and more code into it until the rest of your plugin is essentially empty. At that point, you'll be able to copy your `index.ts`, `plugin.ts`, and the contents of `./np_ready` over into your plugin in the new platform, leaving your legacy shim behind. This carries the added benefit of providing a way for us to introduce helpful tooling in the future, such as [custom eslint rules](https://github.com/elastic/kibana/pull/40537), which could be run against that specific directory to ensure your code is ready to migrate. - -## Keep Kibana fast - -**tl;dr**: Load as much code lazily as possible. -Everyone loves snappy applications with responsive UI and hates spinners. Users deserve the best user experiences regardless of whether they run Kibana locally or in the cloud, regardless of their hardware & environment. -There are 2 main aspects of the perceived speed of an application: loading time and responsiveness to user actions. -New platform loads and bootstraps **all** the plugins whenever a user lands on any page. It means that adding every new application affects overall **loading performance** in the new platform, as plugin code is loaded **eagerly** to initialize the plugin and provide plugin API to dependent plugins. -However, it's usually not necessary that the whole plugin code should be loaded and initialized at once. The plugin could keep on loading code covering API functionality on Kibana bootstrap but load UI related code lazily on-demand, when an application page or management section is mounted. -Always prefer to require UI root components lazily when possible (such as in mount handlers). Even if their size may seem negligible, they are likely using some heavy-weight libraries that will also be removed from the initial plugin bundle, therefore, reducing its size by a significant amount. - -```typescript -import { Plugin, CoreSetup, AppMountParameters } from 'src/core/public'; -export class MyPlugin implements Plugin { - setup(core: CoreSetup, plugins: SetupDeps) { - core.application.register({ - id: 'app', - title: 'My app', - async mount(params: AppMountParameters) { - const { mountApp } = await import('./app/mount_app'); - return mountApp(await core.getStartServices(), params); - }, - }); - plugins.management.sections.section.kibana.registerApp({ - id: 'app', - title: 'My app', - order: 1, - async mount(params) { - const { mountManagementSection } = await import('./app/mount_management_section'); - return mountManagementSection(coreSetup, params); - }, - }); - return { - doSomething() {}, - }; - } -} -``` - -#### How to understand how big the bundle size of my plugin is? - -New platform plugins are distributed as a pre-built with `@kbn/optimizer` package artifacts. It allows us to get rid of the shipping of `optimizer` in the distributable version of Kibana. -Every NP plugin artifact contains all plugin dependencies required to run the plugin, except some stateful dependencies shared across plugin bundles via `@kbn/ui-shared-deps`. -It means that NP plugin artifacts tend to have a bigger size than the legacy platform version. -To understand the current size of your plugin artifact, run `@kbn/optimizer` as - -```bash -node scripts/build_kibana_platform_plugins.js --dist --profile --focus=my_plugin -``` - -and check the output in the `target` sub-folder of your plugin folder - -```bash -ls -lh plugins/my_plugin/target/public/ -# output -# an async chunk loaded on demand -... 262K 0.plugin.js -# eagerly loaded chunk -... 50K my_plugin.plugin.js -``` - -you might see at least one js bundle - `my_plugin.plugin.js`. This is the only artifact loaded by the platform during bootstrap in the browser. The rule of thumb is to keep its size as small as possible. -Other lazily loaded parts of your plugin present in the same folder as separate chunks under `{number}.plugin.js` names. -If you want to investigate what your plugin bundle consists of you need to run `@kbn/optimizer` with `--profile` flag to get generated [webpack stats file](https://webpack.js.org/api/stats/). - -```bash -node scripts/build_kibana_platform_plugins.js --dist --no-examples --profile -``` - -Many OSS tools are allowing you to analyze generated stats file - -- [an official tool](http://webpack.github.io/analyse/#modules) from webpack authors -- [webpack-visualizer](https://chrisbateman.github.io/webpack-visualizer/) - -## Frequently asked questions - -### Is migrating a plugin an all-or-nothing thing? - -It doesn't have to be. Within the Kibana repo, you can have a new platform plugin with the same name as a legacy plugin. - -Technically speaking, you could move all of your server-side code to the new platform and leave the legacy browser-side code where it is. You can even move only a portion of code on your server at a time, like on a route by route basis for example. - -For any new plugin APIs being defined as part of this process, it is recommended to create those APIs in new platform plugins, and then core will pass them down into the legacy world to be used there. This leaves one less thing you need to migrate. - -### Do plugins need to be converted to TypeScript? - -No. That said, the migration process will require a lot of refactoring, and TypeScript will make this dramatically easier and less risky. Independent of the new platform effort, our goals are to convert the entire Kibana repo to TypeScript over time, so now is a great time to do it. - -At the very least, any plugin exposing an extension point should do so with first-class type support so downstream plugins that _are_ using TypeScript can depend on those types. - -### Can static code be shared between plugins? - -**tl;dr** Yes, but it should be limited to pure functional code that does not depend on outside state from the platform or a plugin. - -#### Background - -> Don't care why, just want to know how? Skip to the ["how" section below](#how-to-decide-what-code-can-be-statically-imported). - -Legacy Kibana has never run as a single page application. Each plugin has it's own entry point and gets "ownership" of every module it imports when it is loaded into the browser. This has allowed stateful modules to work without breaking other plugins because each time the user navigates to a new plugin, the browser reloads with a different entry bundle, clearing the state of the previous plugin. - -Because of this "feature" many undesirable things developed in the legacy platform: - -- We had to invent an unconventional and fragile way of allowing plugins to integrate and communicate with one another, `uiExports`. -- It has never mattered if shared modules in `ui/public` were stateful or cleaned up after themselves, so many of them behave like global singletons. These modules could never work in single-page application because of this state. -- We've had to ship Webpack with Kibana in production so plugins could be disabled or installed and still have access to all the "platform" features of `ui/public` modules and all the `uiExports` would be present for any enabled plugins. -- We've had to require that 3rd-party plugin developers release a new version of their plugin for each and every version of Kibana because these shared modules have no stable API and are coupled tightly both to their consumers and the Kibana platform. - -The New Platform's primary goal is to make developing Kibana plugins easier, both for developers at Elastic and in the community. The approach we've chosen is to enable plugins to integrate and communicate _at runtime_ rather than at build time. By wiring services and plugins up at runtime, we can ship stable APIs that do not have to be compiled into every plugin and instead live inside a solid core that each plugin gets connected to when it executes. - -This applies to APIs that plugins expose as well. In the new platform, plugins can communicate through an explicit interface rather than importing all the code from one another and having to recompile Webpack bundles when a plugin is disabled or a new plugin is installed. - -You've probably noticed that this is not the typical way a JavaScript developer works. We're used to importing code at the top of files (and for some use-cases this is still fine). However, we're not building a typical JavaScript application, we're building an application that is installed into a dynamic system (the Kibana Platform). - -#### What goes wrong if I do share modules with state? - -One goal of a stable Kibana core API is to allow Kibana instances to run plugins with varying minor versions, e.g. Kibana 8.4.0 running PluginX 8.0.1 and PluginY 8.2.5. This will be made possible by building each plugin into an “immutable bundle” that can be installed into Kibana. You can think of an immutable bundle as code that doesn't share any imported dependencies with any other bundles, that is all it's dependencies are bundled together. - -This method of building and installing plugins comes with side effects which are important to be aware of when developing a plugin. - -- **Any code you export to other plugins will get copied into their bundles.** If a plugin is built for 8.1 and is running on Kibana 8.2, any modules it imported that changed will not be updated in that plugin. -- **When a plugin is disabled, other plugins can still import its static exports.** This can make code difficult to reason about and result in poor user experience. For example, users generally expect that all of a plugin’s features will be disabled when the plugin is disabled. If another plugin imports a disabled plugin’s feature and exposes it to the user, then users will be confused about whether that plugin really is disabled or not. -- **Plugins cannot share state by importing each others modules.** Sharing state via imports does not work because exported modules will be copied into plugins that import them. Let’s say your plugin exports a module that’s imported by other plugins. If your plugin populates state into this module, a natural expectation would be that the other plugins now have access to this state. However, because those plugins have copies of the exported module, this assumption will be incorrect. - -#### How to decide what code can be statically imported - -The general rule of thumb here is: any module that is not purely functional should not be shared statically, and instead should be exposed at runtime via the plugin's `setup` and/or `start` contracts. - -Ask yourself these questions when deciding to share code through static exports or plugin contracts: - -- Is its behavior dependent on any state populated from my plugin? -- If a plugin uses an old copy (from an older version of Kibana) of this module, will it still break? - -If you answered yes to any of the above questions, you probably have an impure module that cannot be shared across plugins. Another way to think about this: if someone literally copied and pasted your exported module into their plugin, would it break if: - -- Your original module changed in a future version and the copy was the old version; or -- If your plugin doesn’t have access to the copied version in the other plugin (because it doesn't know about it). - -If your module were to break for either of these reasons, it should not be exported statically. This can be more easily illustrated by examples of what can and cannot be exported statically. - -Examples of code that could be shared statically: - -- Constants. Strings and numbers that do not ever change (even between Kibana versions) - - If constants do change between Kibana versions, then they should only be exported statically if the old value would not _break_ if it is still used. For instance, exporting a constant like `VALID_INDEX_NAME_CHARACTERS` would be fine, but exporting a constant like `API_BASE_PATH` would not because if this changed, old bundles using the previous value would break. -- React components that do not depend on module state. - - Make sure these components are not dependent on or pre-wired to Core services. In many of these cases you can export a HOC that takes the Core service and returns a component wired up to that particular service instance. - - These components do not need to be "pure" in the sense that they do not use React state or React hooks, they just cannot rely on state inside the module or any modules it imports. -- Pure computation functions, for example lodash-like functions like `mapValues`. - -Examples of code that could **not** be shared statically and how to fix it: - -- A function that calls a Core service, but does not take that service as a parameter. - - - If the function does not take a client as an argument, it must have an instance of the client in its internal state, populated by your plugin. This would not work across plugin boundaries because your plugin would not be able to call `setClient` in the copy of this module in other plugins: - - ```js - let esClient; - export const setClient = (client) => (esClient = client); - export const query = (params) => esClient.search(params); - ``` - - - This could be fixed by requiring the calling code to provide the client: - - ```js - export const query = (esClient, params) => esClient.search(params); - ``` - -- A function that allows other plugins to register values that get pushed into an array defined internally to the module. - - - The values registered would only be visible to the plugin that imported it. Each plugin would essentially have their own registry of visTypes that is not visible to any other plugins. - - ```js - const visTypes = []; - export const registerVisType = (visType) => visTypes.push(visType); - export const getVisTypes = () => visTypes; - ``` - - - For state that does need to be shared across plugins, you will need to expose methods in your plugin's `setup` and `start` contracts. - - ```js - class MyPlugin { - constructor() { - this.visTypes = []; - } - setup() { - return { - registerVisType: (visType) => this.visTypes.push(visType), - }; - } - - start() { - return { - getVisTypes: () => this.visTypes, - }; - } - } - ``` - -In any case, you will also need to carefully consider backward compatibility (BWC). Whatever you choose to export will need to work for the entire major version cycle (eg. Kibana 8.0-8.9), regardless of which version of the export a plugin has bundled and which minor version of Kibana they're using. Breaking changes to static exports are only allowed in major versions. However, during the 7.x cycle, all of these APIs are considered "experimental" and can be broken at any time. We will not consider these APIs stable until 8.0 at the earliest. - -#### Concrete Example - -Ok, you've decided you want to export static code from your plugin, how do you do it? The New Platform only considers values exported from `my_plugin/public` and `my_plugin/server` to be stable. The linter will only let you import statically from these top-level modules. In the future, our tooling will enforce that these APIs do not break between minor versions. All code shared among plugins should be exported in these modules like so: - -```ts -// my_plugin/public/index.ts -export { MyPureComponent } from './components'; - -// regular plugin export used by core to initialize your plugin -export const plugin = ...; -``` - -These can then be imported using relative paths from other plugins: - -```ts -// my_other_plugin/public/components/my_app.ts -import { MyPureComponent } from '../my_plugin/public'; -``` - -If you have code that should be available to other plugins on both the client and server, you can have a common directory. _See [How is "common" code shared on both the client and server?](#how-is-common-code-shared-on-both-the-client-and-server)_ - -### How can I avoid passing Core services deeply within my UI component tree? - -There are some Core services that are purely presentational, for example `core.overlays.openModal()` or `core.application.createLink()` where UI code does need access to these deeply within your application. However, passing these services down as props throughout your application leads to lots of boilerplate. To avoid this, you have three options: - -1. Use an abstraction layer, like Redux, to decouple your UI code from core (**this is the highly preferred option**); or - - [redux-thunk](https://github.com/reduxjs/redux-thunk#injecting-a-custom-argument) and [redux-saga](https://redux-saga.js.org/docs/api/#createsagamiddlewareoptions) already have ways to do this. -2. Use React Context to provide these services to large parts of your React tree; or -3. Create a high-order-component that injects core into a React component; or - - This would be a stateful module that holds a reference to Core, but provides it as props to components with a `withCore(MyComponent)` interface. This can make testing components simpler. (Note: this module cannot be shared across plugin boundaries, see above). -4. Create a global singleton module that gets imported into each module that needs it. (Note: this module cannot be shared across plugin boundaries, see above). [Example](https://gist.github.com/epixa/06c8eeabd99da3c7545ab295e49acdc3). - -If you find that you need many different Core services throughout your application, this may be a code smell and could lead to pain down the road. For instance, if you need access to an HTTP Client or SavedObjectsClient in many places in your React tree, it's likely that a data layer abstraction (like Redux) could make developing your plugin much simpler (see option 1). - -Without such an abstraction, you will need to mock out Core services throughout your test suite and will couple your UI code very tightly to Core. However, if you can contain all of your integration points with Core to Redux middleware and/or reducers, you only need to mock Core services once, and benefit from being able to change those integrations with Core in one place rather than many. This will become incredibly handy when Core APIs have breaking changes. - -### How is "common" code shared on both the client and server? - -There is no formal notion of "common" code that can safely be imported from either client-side or server-side code. However, if a plugin author wishes to maintain a set of code in their plugin in a single place and then expose it to both server-side and client-side code, they can do so by exporting in the index files for both the `server` and `public` directories. - -Plugins should not ever import code from deeply inside another plugin (eg. `my_plugin/public/components`) or from other top-level directories (eg. `my_plugin/common/constants`) as these are not checked for breaking changes and are considered unstable and subject to change at any time. You can have other top-level directories like `my_plugin/common`, but our tooling will not treat these as a stable API and linter rules will prevent importing from these directories _from outside the plugin_. - -The benefit of this approach is that the details of where code lives and whether it is accessible in multiple runtimes is an implementation detail of the plugin itself. A plugin consumer that is writing client-side code only ever needs to concern themselves with the client-side contracts being exposed, and the same can be said for server-side contracts on the server. - -A plugin author that decides some set of code should diverge from having a single "common" definition can now safely change the implementation details without impacting downstream consumers. - -_See all [conventions for first-party Elastic plugins](./CONVENTIONS.md)_. - -### When does code go into a plugin, core, or packages? - -This is an impossible question to answer definitively for all circumstances. For each time this question is raised, we must carefully consider to what extent we think that code is relevant to almost everyone developing in Kibana, what license the code is shipping under, which teams are most appropriate to "own" that code, is the code stateless etc. - -As a general rule of thumb, most code in Kibana should exist in plugins. Plugins are the most obvious way that we break Kibana down into sets of specialized domains with controls around interdependency communication and management. It's always possible to move code from a plugin into core if we ever decide to do so, but it's much more disruptive to move code from core to a plugin. - -There is essentially no code that _can't_ exist in a plugin. When in doubt, put the code in a plugin. - -After plugins, core is where most of the rest of the code in Kibana will exist. Functionality that's critical to the reliable execution of the Kibana process belongs in core. Services that will widely be used by nearly every non-trivial plugin in any Kibana install belong in core. Functionality that is too specialized to specific use cases should not be in core, so while something like generic saved objects is a core concern, index patterns are not. - -The packages directory should have the least amount of code in Kibana. Just because some piece of code is not stateful doesn't mean it should go into packages. The packages directory exists to aid us in our quest to centralize as many of our owned dependencies in this single monorepo, so it's the logical place to put things like Kibana specific forks of node modules or vendor dependencies. - -### How do I build my shim for New Platform services? - -Many of the utilities you're using to build your plugins are available in the New Platform or in New Platform plugins. To help you build the shim for these new services, use the tables below to find where the New Platform equivalent lives. - -#### Client-side - -TODO: add links to API docs on items in "New Platform" column. - -##### Core services - -In client code, `core` can be imported in legacy plugins via the `ui/new_platform` module. - -```ts -import { npStart: { core } } from 'ui/new_platform'; -``` - -| Legacy Platform | New Platform | Notes | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `chrome.addBasePath` | [`core.http.basePath.prepend`](/docs/development/core/public/kibana-plugin-core-public.httpsetup.basepath.md) | | -| `chrome.navLinks.update` | [`core.appbase.updater`](/docs/development/core/public/kibana-plugin-core-public.appbase.updater_.md) | Use the `updater$` property when registering your application via `core.application.register` | -| `chrome.breadcrumbs.set` | [`core.chrome.setBreadcrumbs`](/docs/development/core/public/kibana-plugin-core-public.chromestart.setbreadcrumbs.md) | | -| `chrome.getUiSettingsClient` | [`core.uiSettings`](/docs/development/core/public/kibana-plugin-core-public.uisettingsclient.md) | | -| `chrome.helpExtension.set` | [`core.chrome.setHelpExtension`](/docs/development/core/public/kibana-plugin-core-public.chromestart.sethelpextension.md) | | -| `chrome.setVisible` | [`core.chrome.setIsVisible`](/docs/development/core/public/kibana-plugin-core-public.chromestart.setisvisible.md) | | -| `chrome.setRootTemplate` / `chrome.setRootController` | -- | Use application mounting via `core.application.register` (not available to legacy plugins at this time). | -| `import { recentlyAccessed } from 'ui/persisted_log'` | [`core.chrome.recentlyAccessed`](/docs/development/core/public/kibana-plugin-core-public.chromerecentlyaccessed.md) | | -| `ui/capabilities` | [`core.application.capabilities`](/docs/development/core/public/kibana-plugin-core-public.capabilities.md) | | -| `ui/documentation_links` | [`core.docLinks`](/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md) | | -| `ui/kfetch` | [`core.http`](/docs/development/core/public/kibana-plugin-core-public.httpservicebase.md) | API is nearly identical | -| `ui/notify` | [`core.notifications`](/docs/development/core/public/kibana-plugin-core-public.notificationsstart.md) and [`core.overlays`](/docs/development/core/public/kibana-plugin-core-public.overlaystart.md) | Toast messages are in `notifications`, banners are in `overlays`. May be combined later. | -| `ui/routes` | -- | There is no global routing mechanism. Each app [configures its own routing](/rfcs/text/0004_application_service_mounting.md#complete-example). | -| `ui/saved_objects` | [`core.savedObjects`](/docs/development/core/public/kibana-plugin-core-public.savedobjectsstart.md) | Client API is the same | -| `ui/doc_title` | [`core.chrome.docTitle`](/docs/development/core/public/kibana-plugin-core-public.chromedoctitle.md) | | -| `uiExports/injectedVars` / `chrome.getInjected` | [Configure plugin](#configure-plugin) and [`PluginConfigDescriptor.exposeToBrowser`](/docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.exposetobrowser.md) | Can only be used to expose configuration properties | - -_See also: [Public's CoreStart API Docs](/docs/development/core/public/kibana-plugin-core-public.corestart.md)_ - -##### Plugins for shared application services - -In client code, we have a series of plugins which house shared application services which are not technically part of `core`, but are often used in Kibana plugins. - -This table maps some of the most commonly used legacy items to their new platform locations. - -```ts -import { npStart: { plugins } } from 'ui/new_platform'; -``` - -| Legacy Platform | New Platform | Notes | -| ------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -| `import 'ui/apply_filters'` | N/A. Replaced by triggering an APPLY_FILTER_TRIGGER trigger. | Directive is deprecated. | -| `import 'ui/filter_bar'` | `import { FilterBar } from '../data/public'` | Directive is deprecated. | -| `import 'ui/query_bar'` | `import { QueryStringInput } from '../data/public'` | Directives are deprecated. | -| `import 'ui/search_bar'` | `import { SearchBar } from '../data/public'` | Directive is deprecated. | -| `import 'ui/kbn_top_nav'` | `import { TopNavMenu } from '../navigation/public'` | Directive was removed. | -| `ui/saved_objects/components/saved_object_finder` | `import { SavedObjectFinder } from '../saved_objects/public'` | | -| `core_plugins/interpreter` | `plugins.data.expressions` | -| `ui/courier` | `plugins.data.search` | -| `ui/agg_types` | `plugins.data.search.aggs` | Most code is available for static import. Stateful code is part of the `search` service. -| `ui/embeddable` | `plugins.embeddables` | -| `ui/filter_manager` | `plugins.data.filter` | -- | -| `ui/index_patterns` | `plugins.data.indexPatterns` | -| `import 'ui/management'` | `plugins.management.sections` | | -| `import 'ui/registry/field_format_editors'` | `plugins.indexPatternManagement.fieldFormatEditors` | | -| `ui/registry/field_formats` | `plugins.data.fieldFormats` | | -| `ui/registry/feature_catalogue` | `plugins.home.featureCatalogue.register` | Must add `home` as a dependency in your kibana.json. | -| `ui/registry/vis_types` | `plugins.visualizations` | -- | -| `ui/vis` | `plugins.visualizations` | -- | -| `ui/share` | `plugins.share` | `showShareContextMenu` is now called `toggleShareContextMenu`, `ShareContextMenuExtensionsRegistryProvider` is now called `register` | -| `ui/vis/vis_factory` | `plugins.visualizations` | -- | -| `ui/vis/vis_filters` | `plugins.visualizations.filters` | -- | -| `ui/utils/parse_es_interval` | `import { search: { aggs: { parseEsInterval } } } from '../data/public'` | `parseEsInterval`, `ParsedInterval`, `InvalidEsCalendarIntervalError`, `InvalidEsIntervalFormatError` items were moved to the `Data Plugin` as a static code | - -#### Server-side - -##### Core services - -In server code, `core` can be accessed from either `server.newPlatform` or `kbnServer.newPlatform`. There are not currently very many services available on the server-side: - -| Legacy Platform | New Platform | Notes | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| `server.config()` | [`initializerContext.config.create()`](/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md) | Must also define schema. See _[how to configure plugin](#configure-plugin)_ | -| `server.route` | [`core.http.createRouter`](/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.createrouter.md) | [Examples](./MIGRATION_EXAMPLES.md#route-registration) | -| `server.renderApp()` | [`response.renderCoreApp()`](docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.rendercoreapp.md) | [Examples](./MIGRATION_EXAMPLES.md#render-html-content) | -| `server.renderAppWithDefaultConfig()` | [`response.renderAnonymousCoreApp()`](docs/development/core/server/kibana-plugin-core-server.httpresourcesservicetoolkit.renderanonymouscoreapp.md) | [Examples](./MIGRATION_EXAMPLES.md#render-html-content) | -| `request.getBasePath()` | [`core.http.basePath.get`](/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.basepath.md) | | -| `server.plugins.elasticsearch.getCluster('data')` | [`context.core.elasticsearch.dataClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | | -| `server.plugins.elasticsearch.getCluster('admin')` | [`context.core.elasticsearch.adminClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | | -| `server.plugins.elasticsearch.createCluster(...)` | [`core.elasticsearch.legacy.createClient`](/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md) | | -| `server.savedObjects.setScopedSavedObjectsClientFactory` | [`core.savedObjects.setClientFactoryProvider`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.setclientfactoryprovider.md) | | -| `server.savedObjects.addScopedSavedObjectsClientWrapperFactory` | [`core.savedObjects.addClientWrapper`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.addclientwrapper.md) | | -| `server.savedObjects.getSavedObjectsRepository` | [`core.savedObjects.createInternalRepository`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md) [`core.savedObjects.createScopedRepository`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.createscopedrepository.md) | | -| `server.savedObjects.getScopedSavedObjectsClient` | [`core.savedObjects.getScopedClient`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicestart.getscopedclient.md) | | -| `request.getSavedObjectsClient` | [`context.core.savedObjects.client`](/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md) | | -| `request.getUiSettingsService` | [`context.core.uiSettings.client`](/docs/development/core/server/kibana-plugin-core-server.iuisettingsclient.md) | | -| `kibana.Plugin.deprecations` | [Handle plugin configuration deprecations](#handle-plugin-config-deprecations) and [`PluginConfigDescriptor.deprecations`](docs/development/core/server/kibana-plugin-core-server.pluginconfigdescriptor.md) | Deprecations from New Platform are not applied to legacy configuration | -| `kibana.Plugin.savedObjectSchemas` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | -| `kibana.Plugin.mappings` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | -| `kibana.Plugin.migrations` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | -| `kibana.Plugin.savedObjectsManagement` | [`core.savedObjects.registerType`](docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md) | [Examples](./MIGRATION_EXAMPLES.md#saved-objects-types) | - -_See also: [Server's CoreSetup API Docs](/docs/development/core/server/kibana-plugin-core-server.coresetup.md)_ - -##### Plugin services - -| Legacy Platform | New Platform | Notes | -| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----- | -| `server.plugins.xpack_main.registerFeature` | [`plugins.features.registerKibanaFeature`](x-pack/plugins/features/server/plugin.ts) | | -| `server.plugins.xpack_main.feature(pluginID).registerLicenseCheckResultsGenerator` | [`x-pack licensing plugin`](/x-pack/plugins/licensing/README.md) | | - -#### UI Exports - -The legacy platform uses a set of "uiExports" to inject modules from one plugin into other plugins. This mechansim is not necessary in the New Platform because all plugins are executed on the page at once (though only one application) is rendered at a time. - -This table shows where these uiExports have moved to in the New Platform. In most cases, if a uiExport you need is not yet available in the New Platform, you may leave in your legacy plugin for the time being and continue to migrate the rest of your app to the New Platform. - -| Legacy Platform | New Platform | Notes | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `aliases` | | | -| `app` | [`core.application.register`](/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md) | | -| `canvas` | | Should be an API on the canvas plugin. | -| `chromeNavControls` | [`core.chrome.navControls.register{Left,Right}`](/docs/development/core/public/kibana-plugin-core-public.chromenavcontrols.md) | | -| `contextMenuActions` | | Should be an API on the devTools plugin. | -| `devTools` | | | -| `docViews` | [`plugins.discover.docViews.addDocView`](./src/plugins/discover/public/doc_views) | Should be an API on the discover plugin. | -| `embeddableActions` | | Should be an API on the embeddables plugin. | -| `embeddableFactories` | | Should be an API on the embeddables plugin. | -| `fieldFormatEditors` | | | -| `fieldFormats` | [`plugins.data.fieldFormats`](./src/plugins/data/public/field_formats) | | -| `hacks` | n/a | Just run the code in your plugin's `start` method. | -| `home` | [`plugins.home.featureCatalogue.register`](./src/plugins/home/public/feature_catalogue) | Must add `home` as a dependency in your kibana.json. | -| `indexManagement` | | Should be an API on the indexManagement plugin. | -| `injectDefaultVars` | n/a | Plugins will only be able to allow config values for the frontend. See [#41990](https://github.com/elastic/kibana/issues/41990) | -| `inspectorViews` | | Should be an API on the data (?) plugin. | -| `interpreter` | | Should be an API on the interpreter plugin. | -| `links` | n/a | Not necessary, just register your app via `core.application.register` | -| `managementSections` | [`plugins.management.sections.register`](/rfcs/text/0006_management_section_service.md) | | -| `mappings` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `migrations` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `navbarExtensions` | n/a | Deprecated | -| `savedObjectSchemas` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `savedObjectsManagement` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `savedObjectTypes` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `search` | | | -| `shareContextMenuExtensions` | | | -| `taskDefinitions` | | Should be an API on the taskManager plugin. | -| `uiCapabilities` | [`core.application.register`](/docs/development/core/public/kibana-plugin-core-public.applicationsetup.register.md) | | -| `uiSettingDefaults` | [`core.uiSettings.register`](/docs/development/core/server/kibana-plugin-core-server.uisettingsservicesetup.md) | | -| `validations` | | Part of SavedObjects, see [#33587](https://github.com/elastic/kibana/issues/33587) | -| `visEditorTypes` | | | -| `visTypeEnhancers` | | | -| `visTypes` | `plugins.visualizations.types` | | -| `visualize` | | | - -#### Plugin Spec - -| Legacy Platform | New Platform | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `id` | [`manifest.id`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | -| `require` | [`manifest.requiredPlugins`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | -| `version` | [`manifest.version`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | -| `kibanaVersion` | [`manifest.kibanaVersion`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | -| `configPrefix` | [`manifest.configPath`](/docs/development/core/server/kibana-plugin-core-server.pluginmanifest.md) | -| `config` | [export config](#configure-plugin) | -| `deprecations` | [export config](#handle-plugin-configuration-deprecations) | -| `uiExports` | `N/A`. Use platform & plugin public contracts | -| `publicDir` | `N/A`. Platform serves static assets from `/public/assets` folder under `/plugins/{id}/assets/{path*}` URL. | -| `preInit`, `init`, `postInit` | `N/A`. Use NP [lifecycle events](#services) | - -## How to - -### Configure plugin - -Kibana provides ConfigService if a plugin developer may want to support adjustable runtime behavior for their plugins. Access to Kibana config in New platform has been subject to significant refactoring. - -Config service does not provide access to the whole config anymore. New platform plugin cannot read configuration parameters of the core services nor other plugins directly. Use plugin contract to provide data. - -```js -// your-plugin.js -// in Legacy platform -const basePath = config.get('server.basePath'); -// in New platform -const basePath = core.http.basePath.get(request); -``` - -In order to have access to your plugin config, you *should*: - -- Declare plugin specific "configPath" (will fallback to plugin "id" if not specified) in `kibana.json` file. -- Export schema validation for config from plugin's main file. Schema is mandatory. If a plugin reads from the config without schema declaration, ConfigService will throw an error. - -```typescript -// my_plugin/server/index.ts -import { schema, TypeOf } from '@kbn/config-schema'; -export const plugin = ... -export const config = { - schema: schema.object(...), -}; -export type MyPluginConfigType = TypeOf; -``` - -- Read config value exposed via initializerContext. No config path is required. - -```typescript -class MyPlugin { - constructor(initializerContext: PluginInitializerContext) { - this.config$ = initializerContext.config.create(); - // or if config is optional: - this.config$ = initializerContext.config.createIfExists(); - } -``` - -If your plugin also have a client-side part, you can also expose configuration properties to it using the configuration `exposeToBrowser` allow-list property. - -```typescript -// my_plugin/server/index.ts -import { schema, TypeOf } from '@kbn/config-schema'; -import { PluginConfigDescriptor } from 'kibana/server'; - -const configSchema = schema.object({ - secret: schema.string({ defaultValue: 'Only on server' }), - uiProp: schema.string({ defaultValue: 'Accessible from client' }), -}); - -type ConfigType = TypeOf; - -export const config: PluginConfigDescriptor = { - exposeToBrowser: { - uiProp: true, - }, - schema: configSchema, -}; -``` - -Configuration containing only the exposed properties will be then available on the client-side using the plugin's `initializerContext`: - -```typescript -// my_plugin/public/index.ts -interface ClientConfigType { - uiProp: string; -} - -export class Plugin implements Plugin { - constructor(private readonly initializerContext: PluginInitializerContext) {} - - public async setup(core: CoreSetup, deps: {}) { - const config = this.initializerContext.config.get(); - // ... - } -``` - -All plugins are considered enabled by default. If you want to disable your plugin by default, you could declare the `enabled` flag in plugin config. This is a special Kibana platform key. The platform reads its value and won't create a plugin instance if `enabled: false`. - -```js -export const config = { - schema: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), -}; -``` - -#### Handle plugin configuration deprecations - -If your plugin have deprecated properties, you can describe them using the `deprecations` config descriptor field. - -The system is quite similar to the legacy plugin's deprecation management. The most important difference -is that deprecations are managed on a per-plugin basis, meaning that you don't need to specify the whole -property path, but use the relative path from your plugin's configuration root. - -```typescript -// my_plugin/server/index.ts -import { schema, TypeOf } from '@kbn/config-schema'; -import { PluginConfigDescriptor } from 'kibana/server'; - -const configSchema = schema.object({ - newProperty: schema.string({ defaultValue: 'Some string' }), -}); - -type ConfigType = TypeOf; - -export const config: PluginConfigDescriptor = { - schema: configSchema, - deprecations: ({ rename, unused }) => [ - rename('oldProperty', 'newProperty'), - unused('someUnusedProperty'), - ], -}; -``` - -In some cases, accessing the whole configuration for deprecations is necessary. For these edge cases, -`renameFromRoot` and `unusedFromRoot` are also accessible when declaring deprecations. - -```typescript -// my_plugin/server/index.ts -export const config: PluginConfigDescriptor = { - schema: configSchema, - deprecations: ({ renameFromRoot, unusedFromRoot }) => [ - renameFromRoot('oldplugin.property', 'myplugin.property'), - unusedFromRoot('oldplugin.deprecated'), - ], -}; -``` - -Note that deprecations registered in new platform's plugins are not applied to the legacy configuration. -During migration, if you still need the deprecations to be effective in the legacy plugin, you need to declare them in -both plugin definitions. - -### Use scoped services - -Whenever Kibana needs to get access to data saved in elasticsearch, it should perform a check whether an end-user has access to the data. -In the legacy platform, Kibana requires to bind elasticsearch related API with an incoming request to access elasticsearch service on behalf of a user. - -```js -async function handler(req, res) { - const dataCluster = server.plugins.elasticsearch.getCluster('data'); - const data = await dataCluster.callWithRequest(req, 'ping'); -} -``` - -The new platform introduced [a handler interface](/rfcs/text/0003_handler_interface.md) on the server-side to perform that association internally. Core services, that require impersonation with an incoming request, are -exposed via `context` argument of [the request handler interface.](/docs/development/core/server/kibana-plugin-core-server.requesthandler.md) -The above example looks in the new platform as - -```js -async function handler(context, req, res) { - const data = await context.core.elasticsearch.adminClient.callAsInternalUser('ping'); -} -``` - -The [request handler context](/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md) exposed the next scoped **core** services: - -| Legacy Platform | New Platform | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `request.getSavedObjectsClient` | [`context.savedObjects.client`](/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md) | -| `server.plugins.elasticsearch.getCluster('admin')` | [`context.elasticsearch.adminClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | -| `server.plugins.elasticsearch.getCluster('data')` | [`context.elasticsearch.dataClient`](/docs/development/core/server/kibana-plugin-core-server.iscopedclusterclient.md) | -| `request.getUiSettingsService` | [`context.uiSettings.client`](/docs/development/core/server/kibana-plugin-core-server.iuisettingsclient.md) | - -#### Declare a custom scoped service - -Plugins can extend the handler context with custom API that will be available to the plugin itself and all dependent plugins. -For example, the plugin creates a custom elasticsearch client and want to use it via the request handler context: - -```ts -import { CoreSetup, IScopedClusterClient } from 'kibana/server'; - -export interface MyPluginContext { - client: IScopedClusterClient; -} - -// extend RequestHandlerContext when a dependent plugin imports MyPluginContext from the file -declare module 'src/core/server' { - interface RequestHandlerContext { - myPlugin?: MyPluginContext; - } -} - -class Plugin { - setup(core: CoreSetup) { - const client = core.elasticsearch.createClient('myClient'); - core.http.registerRouteHandlerContext('myPlugin', (context, req, res) => { - return { client: client.asScoped(req) }; - }); - - router.get( - { path: '/api/my-plugin/', validate }, - async (context, req, res) => { - const data = await context.myPlugin.client.callAsCurrentUser('endpoint'); - ... - } - ); - } -``` - -### Mock new platform services in tests - -#### Writing mocks for your plugin - -Core services already provide mocks to simplify testing and make sure plugins always rely on valid public contracts: - -```typescript -// my_plugin/server/plugin.test.ts -import { configServiceMock } from 'src/core/server/mocks'; - -const configService = configServiceMock.create(); -configService.atPath.mockReturnValue(config$); -… -const plugin = new MyPlugin({ configService }, …); -``` - -Or if you need to get the whole core `setup` or `start` contracts: - -```typescript -// my_plugin/public/plugin.test.ts -import { coreMock } from 'src/core/public/mocks'; - -const coreSetup = coreMock.createSetup(); -coreSetup.uiSettings.get.mockImplementation((key: string) => { - … -}); -… -const plugin = new MyPlugin(coreSetup, ...); -``` - -Although it isn't mandatory, we strongly recommended you export your plugin mocks as well, in order for dependent plugins to use them in tests. Your plugin mocks should be exported from the root `/server` and `/public` directories in your plugin: - -```typescript -// my_plugin/server/mocks.ts or my_plugin/public/mocks.ts -const createSetupContractMock = () => { - const startContract: jest.Mocked= { - isValid: jest.fn(); - } - // here we already type check as TS infers to the correct type declared above - startContract.isValid.mockReturnValue(true); - return startContract; -} - -export const myPluginMocks = { - createSetup: createSetupContractMock, - createStart: … -} -``` - -Plugin mocks should consist of mocks for *public APIs only*: setup/start/stop contracts. Mocks aren't necessary for pure functions as other plugins can call the original implementation in tests. - -#### Using mocks in your tests - -During the migration process, it is likely you are preparing your plugin by shimming in new platform-ready dependencies via the legacy `ui/new_platform` module: - -```typescript -import { npSetup, npStart } from 'ui/new_platform'; -``` - -If you are using this approach, the easiest way to mock core and new platform-ready plugins in your legacy tests is to mock the `ui/new_platform` module: - -```typescript -jest.mock('ui/new_platform'); -``` - -This will automatically mock the services in `ui/new_platform` thanks to the [helpers that have been added](../../src/legacy/ui/public/new_platform/__mocks__/helpers.ts) to that module. - -If others are consuming your plugin's new platform contracts via the `ui/new_platform` module, you'll want to update the helpers as well to ensure your contracts are properly mocked. - -> Note: The `ui/new_platform` mock is only designed for use by old Jest tests. If you are writing new tests, you should structure your code and tests such that you don't need this mock. Instead, you should import the `core` mock directly and instantiate it. - -### Provide Legacy Platform API to the New platform plugin - -#### On the server side - -During migration, you can face a problem that not all API is available in the New platform yet. You can work around this by extending your -new platform plugin with Legacy API: - -- create New platform plugin -- New platform plugin should expose a method `registerLegacyAPI` that allows passing API from the Legacy platform and store it in the NP plugin instance - -```js -class MyPlugin { - public async setup(core){ - return { - registerLegacyAPI: (legacyAPI) => (this.legacyAPI = legacyAPI) - } - } -} -``` - -- The legacy plugin provides API calling `registerLegacyAPI` - -```js -new kibana.Plugin({ - init(server){ - const myPlugin = server.newPlatform.setup.plugins.myPlugin; - if (!myPlugin) { - throw new Error('myPlugin plugin is not available.'); - } - myPlugin.registerLegacyAPI({ ... }); - } -}) -``` - -- The new platform plugin access stored Legacy platform API via `getLegacyAPI` getter. Getter function must have name indicating that’s API provided from the Legacy platform. - -```js -class MyPlugin { - private getLegacyAPI(){ - return this.legacyAPI; - } - public async setup(core){ - const routeHandler = (context, req, req) => { - const legacyApi = this.getLegacyAPI(); - // ... - } - return { - registerLegacyAPI: (legacyAPI) => (this.legacyAPI = legacyAPI) - } - } -} -``` - -#### On the client side - -It's not currently possible to use a similar pattern on the client-side. -Because Legacy platform plugins heavily rely on global angular modules, which aren't available on the new platform. -So you can utilize the same approach for only *stateless Angular components*, as long as they are not consumed by a New Platform application. When New Platform applications are on the page, no legacy code is executed, so the `registerLegacyAPI` function would not be called. - -### Updates an application navlink at runtime - -The application API now provides a way to updates some of a registered application's properties after registration. - -```typescript -// inside your plugin's setup function -export class MyPlugin implements Plugin { - private appUpdater = new BehaviorSubject(() => ({})); - setup({ application }) { - application.register({ - id: 'my-app', - title: 'My App', - updater$: this.appUpdater, - async mount(params) { - const { renderApp } = await import('./application'); - return renderApp(params); - }, - }); - } - start() { - // later, when the navlink needs to be updated - appUpdater.next(() => { - navLinkStatus: AppNavLinkStatus.disabled, - tooltip: 'Application disabled', - }) - } -``` - -### Logging config migration - -[Read](./server/logging/README.md#logging-config-migration) - -### Use HashRouter in migrated apps - -Kibana applications are meant to be leveraging the `ScopedHistory` provided in an app's `mount` function to wire their router. For react, -this is done by using the `react-router-dom` `Router` component: - -```typescript -export const renderApp = async (element: HTMLElement, history: ScopedHistory) => { - render( - - - - - - - - , - element - ); - - return () => { - unmountComponentAtNode(element); - unlisten(); - }; -}; -``` - -Some legacy apps were using `react-router-dom`'s `HashRouter` instead. Using `HashRouter` in a migrated application will cause some route change -events to not be catched by the router, as the `BrowserHistory` used behind the provided scoped history does not emit -the `hashevent` that is required for the `HashRouter` to behave correctly. - -It is strictly recommended to migrate your application's routing to browser history, which is the only routing officially supported by the platform. - -However, during the transition period, it is possible to make the two histories cohabitate by manually emitting the required events from -the scoped to the hash history. You may use this workaround at your own risk. While we are not aware of any problems it currently creates, there may be edge cases that do not work properly. - -```typescript -export const renderApp = async (element: HTMLElement, history: ScopedHistory) => { - render( - - - - - - -
- , - element - ); - - // dispatch synthetic hash change event to update hash history objects - // this is necessary because hash updates triggered by the scoped history will not emit them. - const unlisten = history.listen(() => { - window.dispatchEvent(new HashChangeEvent('hashchange')); - }); - - return () => { - unmountComponentAtNode(element); - // unsubscribe to `history.listen` when unmounting. - unlisten(); - }; -}; -``` diff --git a/src/core/MIGRATION_EXAMPLES.md b/src/core/MIGRATION_EXAMPLES.md deleted file mode 100644 index 3f34742e4486..000000000000 --- a/src/core/MIGRATION_EXAMPLES.md +++ /dev/null @@ -1,1291 +0,0 @@ -# Migration Examples - -This document is a list of examples of how to migrate plugin code from legacy -APIs to their New Platform equivalents. - -- [Migration Examples](#migration-examples) - - [Configuration](#configuration) - - [Declaring config schema](#declaring-config-schema) - - [Using New Platform config in a new plugin](#using-new-platform-config-in-a-new-plugin) - - [Using New Platform config from a Legacy plugin](#using-new-platform-config-from-a-legacy-plugin) - - [Create a New Platform plugin](#create-a-new-platform-plugin) - - [HTTP Routes](#http-routes) - - [1. Legacy route registration](#1-legacy-route-registration) - - [2. New Platform shim using legacy router](#2-new-platform-shim-using-legacy-router) - - [3. New Platform shim using New Platform router](#3-new-platform-shim-using-new-platform-router) - - [4. New Platform plugin](#4-new-platform-plugin) - - [Accessing Services](#accessing-services) - - [Migrating Hapi "pre" handlers](#migrating-hapi-pre-handlers) - - [Simple example](#simple-example) - - [Full Example](#full-example) - - [Chrome](#chrome) - - [Updating an application navlink](#updating-an-application-navlink) - - [Chromeless Applications](#chromeless-applications) - - [Render HTML Content](#render-html-content) - - [Saved Objects types](#saved-objects-types) - - [Concrete example](#concrete-example) - - [Changes in structure compared to legacy](#changes-in-structure-compared-to-legacy) - - [Remarks](#remarks) - - [UiSettings](#uisettings) - - [Elasticsearch client](#elasticsearch-client) - - [Client API Changes](#client-api-changes) - - [Accessing the client from a route handler](#accessing-the-client-from-a-route-handler) - - [Creating a custom client](#creating-a-custom-client) - -## Configuration - -### Declaring config schema - -Declaring the schema of your configuration fields is similar to the Legacy Platform but uses the `@kbn/config-schema` package instead of Joi. This package has full TypeScript support, but may be missing some features you need. Let the Platform team know by opening an issue and we'll add what you're missing. - -```ts -// Legacy config schema -import Joi from 'joi'; - -new kibana.Plugin({ - config() { - return Joi.object({ - enabled: Joi.boolean().default(true), - defaultAppId: Joi.string().default('home'), - index: Joi.string().default('.kibana'), - disableWelcomeScreen: Joi.boolean().default(false), - autocompleteTerminateAfter: Joi.number().integer().min(1).default(100000), - }) - } -}); - -// New Platform equivalent -import { schema, TypeOf } from '@kbn/config-schema'; - -export const config = { - schema: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - defaultAppId: schema.string({ defaultValue: true }), - index: schema.string({ defaultValue: '.kibana' }), - disableWelcomeScreen: schema.boolean({ defaultValue: false }), - autocompleteTerminateAfter: schema.duration({ min: 1, defaultValue: 100000 }), - }) -}; - -// @kbn/config-schema is written in TypeScript, so you can use your schema -// definition to create a type to use in your plugin code. -export type MyPluginConfig = TypeOf; -``` - -### Using New Platform config in a new plugin - -After setting the config schema for your plugin, you might want to reach the configuration in the plugin. -It is provided as part of the [PluginInitializerContext](../../docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md) -in the *constructor* of the plugin: - -```ts -// myPlugin/(public|server)/index.ts - -import { PluginInitializerContext } from 'kibana/server'; -import { MyPlugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new MyPlugin(initializerContext); -} -``` - -```ts -// myPlugin/(public|server)/plugin.ts - -import { Observable } from 'rxjs'; -import { first } from 'rxjs/operators'; -import { CoreSetup, Logger, Plugin, PluginInitializerContext, PluginName } from 'kibana/server'; -import { MyPlugin } from './plugin'; - -export class MyPlugin implements Plugin { - private readonly config$: Observable; - private readonly log: Logger; - - constructor(private readonly initializerContext: PluginInitializerContext) { - this.log = initializerContext.logger.get(); - this.config$ = initializerContext.config.create(); - } - - public async setup(core: CoreSetup, deps: Record) { - const isEnabled = await this.config$.pipe(first()).toPromise(); - ... - } - ... -} -} -``` - -Additionally, some plugins need to read other plugins' config to act accordingly (like timing out a request, matching ElasticSearch's timeout). For those use cases, the plugin can rely on the *globalConfig* and *env* properties in the context: - -```ts -export class MyPlugin implements Plugin { -... - public async setup(core: CoreSetup, deps: Record) { - const { mode: { dev }, packageInfo: { version } } = this.initializerContext.env; - const { elasticsearch: { shardTimeout }, path: { data } } = await this.initializerContext.config.legacy.globalConfig$ - .pipe(first()).toPromise(); - ... - } -``` - -### Using New Platform config from a Legacy plugin - -During the migration process, you'll want to migrate your schema to the new -format. However, legacy plugins cannot directly get access to New Platform's -config service due to the way that config is tied to the `kibana.json` file -(which does not exist for legacy plugins). - -There is a workaround though: - -- Create a New Platform plugin that contains your plugin's config schema in the new format -- Expose the config from the New Platform plugin in its setup contract -- Read the config from the setup contract in your legacy plugin - -#### Create a New Platform plugin - -For example, if wanted to move the legacy `timelion` plugin's configuration to -the New Platform, we could create a NP plugin with the same name in -`src/plugins/timelion` with the following files: - -```json5 -// src/plugins/timelion/kibana.json -{ - "id": "timelion", - "server": true -} -``` - -```ts -// src/plugins/timelion/server/index.ts -import { schema, TypeOf } from '@kbn/config-schema'; -import { PluginInitializerContext } from 'src/core/server'; -import { TimelionPlugin } from './plugin'; - -export const config = { - schema: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - }); -} - -export const plugin = (initContext: PluginInitializerContext) => new TimelionPlugin(initContext); - -export type TimelionConfig = TypeOf; -export { TimelionSetup } from './plugin'; -``` - -```ts -// src/plugins/timelion/server/plugin.ts -import { PluginInitializerContext, Plugin, CoreSetup } from '../../core/server'; -import { TimelionConfig } from '.'; - -export class TimelionPlugin implements Plugin { - constructor(private readonly initContext: PluginInitializerContext) {} - - public setup(core: CoreSetup) { - return { - __legacy: { - config$: this.initContext.config.create(), - }, - }; - } - - public start() {} - public stop() {} -} - -export interface TimelionSetup { - /** @deprecated */ - __legacy: { - config$: Observable; - }; -} -``` - -With the New Platform plugin in place, you can then read this `config$` -Observable from your legacy plugin: - -```ts -import { take } from 'rxjs/operators'; - -new kibana.Plugin({ - async init(server) { - const { config$ } = server.newPlatform.setup.plugins.timelion; - const currentConfig = await config$.pipe(take(1)).toPromise(); - } -}); -``` - -## HTTP Routes - -In the legacy platform, plugins have direct access to the Hapi `server` object -which gives full access to all of Hapi's API. In the New Platform, plugins have -access to the -[HttpServiceSetup](/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md) -interface, which is exposed via the -[CoreSetup](/docs/development/core/server/kibana-plugin-core-server.coresetup.md) -object injected into the `setup` method of server-side plugins. - -This interface has a different API with slightly different behaviors. - -- All input (body, query parameters, and URL parameters) must be validated using - the `@kbn/config-schema` package. If no validation schema is provided, these - values will be empty objects. -- All exceptions thrown by handlers result in 500 errors. If you need a specific - HTTP error code, catch any exceptions in your handler and construct the - appropriate response using the provided response factory. While you can - continue using the `boom` module internally in your plugin, the framework does - not have native support for converting Boom exceptions into HTTP responses. - -Because of the incompatibility between the legacy and New Platform HTTP Route -API's it might be helpful to break up your migration work into several stages. - -### 1. Legacy route registration - -```ts -// legacy/plugins/myplugin/index.ts -import Joi from 'joi'; - -new kibana.Plugin({ - init(server) { - server.route({ - path: '/api/demoplugin/search', - method: 'POST', - options: { - validate: { - payload: Joi.object({ - field1: Joi.string().required(), - }), - } - }, - handler(req, h) { - return { message: `Received field1: ${req.payload.field1}` }; - } - }); - } -}); -``` - -### 2. New Platform shim using legacy router - -Create a New Platform shim and inject the legacy `server.route` into your -plugin's setup function. - -```ts -// legacy/plugins/demoplugin/index.ts -import { Plugin, LegacySetup } from './server/plugin'; -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - // core shim - const coreSetup: server.newPlatform.setup.core; - const pluginSetup = {}; - const legacySetup: LegacySetup = { - route: server.route - }; - - new Plugin().setup(coreSetup, pluginSetup, legacySetup); - } - } -} -``` - -```ts -// legacy/plugins/demoplugin/server/plugin.ts -import { CoreSetup } from 'src/core/server'; -import { Legacy } from 'kibana'; - -export interface LegacySetup { - route: Legacy.Server['route']; -}; - -export interface DemoPluginsSetup {}; - -export class Plugin { - public setup(core: CoreSetup, plugins: DemoPluginsSetup, __LEGACY: LegacySetup) { - __LEGACY.route({ - path: '/api/demoplugin/search', - method: 'POST', - options: { - validate: { - payload: Joi.object({ - field1: Joi.string().required(), - }), - } - }, - async handler(req) { - return { message: `Received field1: ${req.payload.field1}` }; - }, - }); - } -} -``` - -### 3. New Platform shim using New Platform router - -We now switch the shim to use the real New Platform HTTP API's in `coreSetup` -instead of relying on the legacy `server.route`. Since our plugin is now using -the New Platform API's we are guaranteed that our HTTP route handling is 100% -compatible with the New Platform. As a result, we will also have to adapt our -route registration accordingly. - -```ts -// legacy/plugins/demoplugin/index.ts -import { Plugin } from './server/plugin'; -export default (kibana) => { - return new kibana.Plugin({ - id: 'demo_plugin', - - init(server) { - // core shim - const coreSetup = server.newPlatform.setup.core; - const pluginSetup = {}; - - new Plugin().setup(coreSetup, pluginSetup); - } - } -} -``` - -```ts -// legacy/plugins/demoplugin/server/plugin.ts -import { schema } from '@kbn/config-schema'; -import { CoreSetup } from 'src/core/server'; - -export interface DemoPluginsSetup {}; - -class Plugin { - public setup(core: CoreSetup, pluginSetup: DemoPluginSetup) { - const router = core.http.createRouter(); - router.post( - { - path: '/api/demoplugin/search', - validate: { - body: schema.object({ - field1: schema.string(), - }), - } - }, - (context, req, res) => { - return res.ok({ - body: { - message: `Received field1: ${req.body.field1}` - } - }); - } - ) - } -} -``` - -If your plugin still relies on throwing Boom errors from routes, you can use the `router.handleLegacyErrors` -as a temporary solution until error migration is complete: - -```ts -// legacy/plugins/demoplugin/server/plugin.ts -import { schema } from '@kbn/config-schema'; -import { CoreSetup } from 'src/core/server'; - -export interface DemoPluginsSetup {}; - -class Plugin { - public setup(core: CoreSetup, pluginSetup: DemoPluginSetup) { - const router = core.http.createRouter(); - router.post( - { - path: '/api/demoplugin/search', - validate: { - body: schema.object({ - field1: schema.string(), - }), - } - }, - router.handleLegacyErrors((context, req, res) => { - throw Boom.notFound('not there'); // will be converted into proper New Platform error - }) - ) - } -} -``` - -#### 4. New Platform plugin - -As the final step we delete the shim and move all our code into a New Platform -plugin. Since we were already consuming the New Platform API's no code changes -are necessary inside `plugin.ts`. - -```ts -// Move legacy/plugins/demoplugin/server/plugin.ts -> plugins/demoplugin/server/plugin.ts -``` - -### Accessing Services - -Services in the Legacy Platform were typically available via methods on either -`server.plugins.*`, `server.*`, or `req.*`. In the New Platform, all services -are available via the `context` argument to the route handler. The type of this -argument is the -[RequestHandlerContext](/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md). -The APIs available here will include all Core services and any services -registered by plugins this plugin depends on. - -```ts -new kibana.Plugin({ - init(server) { - const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); - - server.route({ - path: '/api/my-plugin/my-route', - method: 'POST', - async handler(req, h) { - const results = await callWithRequest(req, 'search', query); - return { results }; - } - }); - } -}); - -class Plugin { - public setup(core) { - const router = core.http.createRouter(); - router.post( - { - path: '/api/my-plugin/my-route', - }, - async (context, req, res) => { - const results = await context.elasticsearch.dataClient.callAsCurrentUser('search', query); - return res.ok({ - body: { results } - }); - } - ) - } -} -``` - -### Migrating Hapi "pre" handlers - -In the Legacy Platform, routes could provide a "pre" option in their config to -register a function that should be run prior to the route handler. These -"pre" handlers allow routes to share some business logic that may do some -pre-work or validation. In Kibana, these are often used for license checks. - -The Kibana Platform's HTTP interface does not provide this functionality, -however it is simple enough to port over using a higher-order function that can -wrap the route handler. - -#### Simple example - -In this simple example, a pre-handler is used to either abort the request with -an error or continue as normal. This is a simple "gate-keeping" pattern. - -```ts -// Legacy pre-handler -const licensePreRouting = (request) => { - const licenseInfo = getMyPluginLicenseInfo(request.server.plugins.xpack_main); - if (!licenseInfo.isOneOf(['gold', 'platinum', 'trial'])) { - throw Boom.forbidden(`You don't have the right license for MyPlugin!`); - } -} - -server.route({ - method: 'GET', - path: '/api/my-plugin/do-something', - config: { - pre: [{ method: licensePreRouting }] - }, - handler: (req) => { - return doSomethingInteresting(); - } -}) -``` - -In the Kibana Platform, the same functionality can be acheived by creating a -function that takes a route handler (or factory for a route handler) as an -argument and either invokes it in the successful case or returns an error -response in the failure case. - -We'll call this a "high-order handler" similar to the "high-order component" -pattern common in the React ecosystem. - -```ts -// New Platform high-order handler -const checkLicense = ( - handler: RequestHandler -): RequestHandler => { - return (context, req, res) => { - const licenseInfo = getMyPluginLicenseInfo(context.licensing.license); - - if (licenseInfo.hasAtLeast('gold')) { - return handler(context, req, res); - } else { - return res.forbidden({ body: `You don't have the right license for MyPlugin!` }); - } - } -} - -router.get( - { path: '/api/my-plugin/do-something', validate: false }, - checkLicense(async (context, req, res) => { - const results = doSomethingInteresting(); - return res.ok({ body: results }); - }), -) -``` - -#### Full Example - -In some cases, the route handler may need access to data that the pre-handler -retrieves. In this case, you can utilize a handler _factory_ rather than a raw -handler. - -```ts -// Legacy pre-handler -const licensePreRouting = (request) => { - const licenseInfo = getMyPluginLicenseInfo(request.server.plugins.xpack_main); - if (licenseInfo.isOneOf(['gold', 'platinum', 'trial'])) { - // In this case, the return value of the pre-handler is made available on - // whatever the 'assign' option is in the route config. - return licenseInfo; - } else { - // In this case, the route handler is never called and the user gets this - // error message - throw Boom.forbidden(`You don't have the right license for MyPlugin!`); - } -} - -server.route({ - method: 'GET', - path: '/api/my-plugin/do-something', - config: { - pre: [{ method: licensePreRouting, assign: 'licenseInfo' }] - }, - handler: (req) => { - const licenseInfo = req.pre.licenseInfo; - return doSomethingInteresting(licenseInfo); - } -}) -``` - -In many cases, it may be simpler to duplicate the function call -to retrieve the data again in the main handler. In this other cases, you can -utilize a handler _factory_ rather than a raw handler as the argument to your -high-order handler. This way the high-order handler can pass arbitrary arguments -to the route handler. - -```ts -// New Platform high-order handler -const checkLicense = ( - handlerFactory: (licenseInfo: MyPluginLicenseInfo) => RequestHandler -): RequestHandler => { - return (context, req, res) => { - const licenseInfo = getMyPluginLicenseInfo(context.licensing.license); - - if (licenseInfo.hasAtLeast('gold')) { - const handler = handlerFactory(licenseInfo); - return handler(context, req, res); - } else { - return res.forbidden({ body: `You don't have the right license for MyPlugin!` }); - } - } -} - -router.get( - { path: '/api/my-plugin/do-something', validate: false }, - checkLicense(licenseInfo => async (context, req, res) => { - const results = doSomethingInteresting(licenseInfo); - return res.ok({ body: results }); - }), -) -``` - -## Chrome - -In the Legacy Platform, the `ui/chrome` import contained APIs for a very wide -range of features. In the New Platform, some of these APIs have changed or moved -elsewhere. - -| Legacy Platform | New Platform | Notes | -|-------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `chrome.addBasePath` | [`core.http.basePath.prepend`](/docs/development/core/public/kibana-plugin-public.httpservicebase.basepath.md) | | -| `chrome.breadcrumbs.set` | [`core.chrome.setBreadcrumbs`](/docs/development/core/public/kibana-plugin-public.chromestart.setbreadcrumbs.md) | | -| `chrome.getUiSettingsClient` | [`core.uiSettings`](/docs/development/core/public/kibana-plugin-public.uisettingsclient.md) | | -| `chrome.helpExtension.set` | [`core.chrome.setHelpExtension`](/docs/development/core/public/kibana-plugin-public.chromestart.sethelpextension.md) | | -| `chrome.setVisible` | [`core.chrome.setIsVisible`](/docs/development/core/public/kibana-plugin-public.chromestart.setisvisible.md) | | -| `chrome.getInjected` | [`core.injectedMetadata.getInjected`](/docs/development/core/public/kibana-plugin-public.coresetup.injectedmetadata.md) (temporary) | A temporary API is available to read injected vars provided by legacy plugins. This will be removed after [#41990](https://github.com/elastic/kibana/issues/41990) is completed. | -| `chrome.setRootTemplate` / `chrome.setRootController` | -- | Use application mounting via `core.application.register` (not currently avaiable to legacy plugins). | -| `chrome.navLinks.update` | [`core.appbase.updater`](/docs/development/core/public/kibana-plugin-public.appbase.updater_.md) | Use the `updater$` property when registering your application via `core.application.register` | - -In most cases, the most convenient way to access these APIs will be via the -[AppMountContext](/docs/development/core/public/kibana-plugin-public.appmountcontext.md) -object passed to your application when your app is mounted on the page. - -### Updating an application navlink - -In the legacy platform, the navlink could be updated using `chrome.navLinks.update` - -```ts -uiModules.get('xpack/ml').run(() => { - const showAppLink = xpackInfo.get('features.ml.showLinks', false); - const isAvailable = xpackInfo.get('features.ml.isAvailable', false); - - const navLinkUpdates = { - // hide by default, only show once the xpackInfo is initialized - hidden: !showAppLink, - disabled: !showAppLink || (showAppLink && !isAvailable), - }; - - npStart.core.chrome.navLinks.update('ml', navLinkUpdates); -}); -``` - -In the new platform, navlinks should not be updated directly. Instead, it is now possible to add an `updater` when -registering an application to change the application or the navlink state at runtime. - -```ts -// my_plugin has a required dependencie to the `licensing` plugin -interface MyPluginSetupDeps { - licensing: LicensingPluginSetup; -} - -export class MyPlugin implements Plugin { - setup({ application }, { licensing }: MyPluginSetupDeps) { - const updater$ = licensing.license$.pipe( - map(license => { - const { hidden, disabled } = calcStatusFor(license); - if (hidden) return { navLinkStatus: AppNavLinkStatus.hidden }; - if (disabled) return { navLinkStatus: AppNavLinkStatus.disabled }; - return { navLinkStatus: AppNavLinkStatus.default }; - }) - ); - - application.register({ - id: 'my-app', - title: 'My App', - updater$, - async mount(params) { - const { renderApp } = await import('./application'); - return renderApp(params); - }, - }); - } -``` - -## Chromeless Applications - -In Kibana, a "chromeless" application is one where the primary Kibana UI components -such as header or navigation can be hidden. In the legacy platform these were referred to -as "hidden" applications, and were set via the `hidden` property in a Kibana plugin. -Chromeless applications are also not displayed in the left navbar. - -To mark an application as chromeless, specify `chromeless: false` when registering your application -to hide the chrome UI when the application is mounted: - -```ts -application.register({ - id: 'chromeless', - chromeless: true, - async mount(context, params) { - /* ... */ - }, -}); -``` - -If you wish to render your application at a route that does not follow the `/app/${appId}` pattern, -this can be done via the `appRoute` property. Doing this currently requires you to register a server -route where you can return a bootstrapped HTML page for your application bundle. Instructions on -registering this server route is covered in the next section: [Render HTML Content](#render-html-content). - -```ts -application.register({ - id: 'chromeless', - appRoute: '/chromeless', - chromeless: true, - async mount(context, params) { - /* ... */ - }, -}); -``` - -## Render HTML Content - -You can return a blank HTML page bootstrapped with the core application bundle from an HTTP route handler -via the `httpResources` service. You may wish to do this if you are rendering a chromeless application with a -custom application route or have other custom rendering needs. - -```typescript -httpResources.register( - { path: '/chromeless', validate: false }, - (context, request, response) => { - //... some logic - return response.renderCoreApp(); - } -); -``` - -You can also specify to exclude user data from the bundle metadata. User data -comprises all UI Settings that are *user provided*, then injected into the page. -You may wish to exclude fetching this data if not authorized or to slim the page -size. - -```typescript -httpResources.register( - { path: '/', validate: false, options: { authRequired: false } }, - (context, request, response) => { - //... some logic - return response.renderAnonymousCoreApp(); - } -); -``` - -## Saved Objects types - -In the legacy platform, saved object types were registered using static definitions in the `uiExports` part of -the plugin manifest. - -In the new platform, all these registration are to be performed programmatically during your plugin's `setup` phase, -using the core `savedObjects`'s `registerType` setup API. - -The most notable difference is that in the new platform, the type registration is performed in a single call to -`registerType`, passing a new `SavedObjectsType` structure that is a superset of the legacy `schema`, `migrations` -`mappings` and `savedObjectsManagement`. - -### Concrete example - -Let say we have the following in a legacy plugin: - -```js -// src/legacy/core_plugins/my_plugin/index.js -import mappings from './mappings.json'; -import { migrations } from './migrations'; - -new kibana.Plugin({ - init(server){ - // [...] - }, - uiExports: { - mappings, - migrations, - savedObjectSchemas: { - 'first-type': { - isNamespaceAgnostic: true, - }, - 'second-type': { - isHidden: true, - }, - }, - savedObjectsManagement: { - 'first-type': { - isImportableAndExportable: true, - icon: 'myFirstIcon', - defaultSearchField: 'title', - getTitle(obj) { - return obj.attributes.title; - }, - getEditUrl(obj) { - return `/some-url/${encodeURIComponent(obj.id)}`; - }, - }, - 'second-type': { - isImportableAndExportable: false, - icon: 'mySecondIcon', - getTitle(obj) { - return obj.attributes.myTitleField; - }, - getInAppUrl(obj) { - return { - path: `/some-url/${encodeURIComponent(obj.id)}`, - uiCapabilitiesPath: 'myPlugin.myType.show', - }; - }, - }, - }, - }, -}) -``` - -```json -// src/legacy/core_plugins/my_plugin/mappings.json -{ - "first-type": { - "properties": { - "someField": { - "type": "text" - }, - "anotherField": { - "type": "text" - } - } - }, - "second-type": { - "properties": { - "textField": { - "type": "text" - }, - "boolField": { - "type": "boolean" - } - } - } -} -``` - -```js -// src/legacy/core_plugins/my_plugin/migrations.js -export const migrations = { - 'first-type': { - '1.0.0': migrateFirstTypeToV1, - '2.0.0': migrateFirstTypeToV2, - }, - 'second-type': { - '1.5.0': migrateSecondTypeToV15, - } -} -``` - -To migrate this, we will have to regroup the declaration per-type. That would become: - -First type: - -```typescript -// src/plugins/my_plugin/server/saved_objects/first_type.ts -import { SavedObjectsType } from 'src/core/server'; - -export const firstType: SavedObjectsType = { - name: 'first-type', - hidden: false, - namespaceType: 'agnostic', - mappings: { - properties: { - someField: { - type: 'text', - }, - anotherField: { - type: 'text', - }, - }, - }, - migrations: { - '1.0.0': migrateFirstTypeToV1, - '2.0.0': migrateFirstTypeToV2, - }, - management: { - importableAndExportable: true, - icon: 'myFirstIcon', - defaultSearchField: 'title', - getTitle(obj) { - return obj.attributes.title; - }, - getEditUrl(obj) { - return `/some-url/${encodeURIComponent(obj.id)}`; - }, - }, -}; -``` - -Second type: - -```typescript -// src/plugins/my_plugin/server/saved_objects/second_type.ts -import { SavedObjectsType } from 'src/core/server'; - -export const secondType: SavedObjectsType = { - name: 'second-type', - hidden: true, - namespaceType: 'single', - mappings: { - properties: { - textField: { - type: 'text', - }, - boolField: { - type: 'boolean', - }, - }, - }, - migrations: { - '1.5.0': migrateSecondTypeToV15, - }, - management: { - importableAndExportable: false, - icon: 'mySecondIcon', - getTitle(obj) { - return obj.attributes.myTitleField; - }, - getInAppUrl(obj) { - return { - path: `/some-url/${encodeURIComponent(obj.id)}`, - uiCapabilitiesPath: 'myPlugin.myType.show', - }; - }, - }, -}; -``` - -Registration in the plugin's setup phase: - -```typescript -// src/plugins/my_plugin/server/plugin.ts -import { firstType, secondType } from './saved_objects'; - -export class MyPlugin implements Plugin { - setup({ savedObjects }) { - savedObjects.registerType(firstType); - savedObjects.registerType(secondType); - } -} -``` - -### Changes in structure compared to legacy - -The NP `registerType` expected input is very close to the legacy format. However, there are some minor changes: - -- The `schema.isNamespaceAgnostic` property has been renamed: `SavedObjectsType.namespaceType`. It no longer accepts a boolean but instead an enum of 'single', 'multiple', or 'agnostic' (see [SavedObjectsNamespaceType](/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md)). - -- The `schema.indexPattern` was accepting either a `string` or a `(config: LegacyConfig) => string`. `SavedObjectsType.indexPattern` only accepts a string, as you can access the configuration during your plugin's setup phase. - -- The `savedObjectsManagement.isImportableAndExportable` property has been renamed: `SavedObjectsType.management.importableAndExportable` - -- The migration function signature has changed: -In legacy, it was `(doc: SavedObjectUnsanitizedDoc, log: SavedObjectsMigrationLogger) => SavedObjectUnsanitizedDoc;` -In new platform, it is now `(doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) => SavedObjectUnsanitizedDoc;` - -With context being: - -```typescript -export interface SavedObjectMigrationContext { - log: SavedObjectsMigrationLogger; -} -``` - -The changes is very minor though. The legacy migration: - -```js -const migration = (doc, log) => {...} -``` - -Would be converted to: - -```typescript -const migration: SavedObjectMigrationFn = (doc, { log }) => {...} -``` - -### Remarks - -The `registerType` API will throw if called after the service has started, and therefor cannot be used from -legacy plugin code. Legacy plugins should use the legacy savedObjects service and the legacy way to register -saved object types until migrated. - -## UiSettings -UiSettings defaults registration performed during `setup` phase via `core.uiSettings.register` API. - -```js -// Before: -uiExports: { - uiSettingDefaults: { - 'my-plugin:my-setting': { - name: 'just-work', - value: true, - description: 'make it work', - category: ['my-category'], - }, - } -} -``` - -```ts -// After: -// src/plugins/my-plugin/server/plugin.ts -setup(core: CoreSetup){ - core.uiSettings.register({ - 'my-plugin:my-setting': { - name: 'just-work', - value: true, - description: 'make it work', - category: ['my-category'], - schema: schema.boolean(), - }, - }) -} -``` - -## Elasticsearch client - -The new elasticsearch client is a thin wrapper around `@elastic/elasticsearch`'s `Client` class. Even if the API -is quite close to the legacy client Kibana was previously using, there are some subtle changes to take into account -during migration. - -[Official documentation](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html) - -### Client API Changes - -The most significant changes for the consumers are the following: - -- internal / current user client accessors has been renamed and are now properties instead of functions - - `callAsInternalUser('ping')` -> `asInternalUser.ping()` - - `callAsCurrentUser('ping')` -> `asCurrentUser.ping()` - -- the API now reflects the `Client`'s instead of leveraging the string-based endpoint names the `LegacyAPICaller` was using - -before: - -```ts -const body = await client.callAsInternalUser('indices.get', { index: 'id' }); -``` - -after: - -```ts -const { body } = await client.asInternalUser.indices.get({ index: 'id' }); -``` - -- calling any ES endpoint now returns the whole response object instead of only the body payload - -before: - -```ts -const body = await legacyClient.callAsInternalUser('get', { id: 'id' }); -``` - -after: - -```ts -const { body } = await client.asInternalUser.get({ id: 'id' }); -``` - -Note that more information from the ES response is available: - -```ts -const { - body, // response payload - statusCode, // http status code of the response - headers, // response headers - warnings, // warnings returned from ES - meta // meta information about the request, such as request parameters, number of attempts and so on -} = await client.asInternalUser.get({ id: 'id' }); -``` - -- all API methods are now generic to allow specifying the response body type - -before: - -```ts -const body: GetResponse = await legacyClient.callAsInternalUser('get', { id: 'id' }); -``` - -after: - -```ts -// body is of type `GetResponse` -const { body } = await client.asInternalUser.get({ id: 'id' }); -// fallback to `Record` if unspecified -const { body } = await client.asInternalUser.get({ id: 'id' }); -``` - -- the returned error types changed - -There are no longer specific errors for every HTTP status code (such as `BadRequest` or `NotFound`). A generic -`ResponseError` with the specific `statusCode` is thrown instead. - -before: - -```ts -import { errors } from 'elasticsearch'; -try { - await legacyClient.callAsInternalUser('ping'); -} catch(e) { - if(e instanceof errors.NotFound) { - // do something - } - if(e.status === 401) {} -} -``` - -after: - -```ts -import { errors } from '@elastic/elasticsearch'; -try { - await client.asInternalUser.ping(); -} catch(e) { - if(e instanceof errors.ResponseError && e.statusCode === 404) { - // do something - } - // also possible, as all errors got a name property with the name of the class, - // so this slightly better in term of performances - if(e.name === 'ResponseError' && e.statusCode === 404) { - // do something - } - if(e.statusCode === 401) {...} -} -``` - -- the parameter property names changed from camelCase to snake_case - -Even if technically, the javascript client accepts both formats, the typescript definitions are only defining the snake_case -properties. - -before: - -```ts -legacyClient.callAsCurrentUser('get', { - id: 'id', - storedFields: ['some', 'fields'], -}) -``` - -after: - -```ts -client.asCurrentUser.get({ - id: 'id', - stored_fields: ['some', 'fields'], -}) -``` - -- the request abortion API changed - -All promises returned from the client API calls now have an `abort` method that can be used to cancel the request. - -before: - -```ts -const controller = new AbortController(); -legacyClient.callAsCurrentUser('ping', {}, { - signal: controller.signal, -}) -// later -controller.abort(); -``` - -after: - -```ts -const request = client.asCurrentUser.ping(); -// later -request.abort(); -``` - -- it is now possible to override headers when performing specific API calls. - -Note that doing so is strongly discouraged due to potential side effects with the ES service internal -behavior when scoping as the internal or as the current user. - -```ts -const request = client.asCurrentUser.ping({}, { - headers: { - authorization: 'foo', - custom: 'bar', - } -}); -``` - -- the new client doesn't provide exhaustive typings for the response object yet. You might have to copy -response type definitions from the Legacy Elasticsearch library until https://github.com/elastic/elasticsearch-js/pull/970 merged. - -```ts -// platform provides a few typings for internal purposes -import { SearchResponse } from 'src/core/server'; -type SearchSource = {...}; -type SearchBody = SearchResponse; -const { body } = await client.search(...); -interface Info {...} -const { body } = await client.info(...); -``` - -- Functional tests are subject to migration to the new client as well. -before: -```ts -const client = getService('legacyEs'); -``` - -after: -```ts -const client = getService('es'); -``` - -Please refer to the [Breaking changes list](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/breaking-changes.html) -for more information about the changes between the legacy and new client. - -### Accessing the client from a route handler - -Apart from the API format change, accessing the client from within a route handler -did not change. As it was done for the legacy client, a preconfigured scoped client -bound to the request is accessible using `core` context provider: - -before: - -```ts -router.get( - { - path: '/my-route', - }, - async (context, req, res) => { - const { client } = context.core.elasticsearch.legacy; - // call as current user - const res = await client.callAsCurrentUser('ping'); - // call as internal user - const res2 = await client.callAsInternalUser('search', options); - return res.ok({ body: 'ok' }); - } -); -``` - -after: - -```ts -router.get( - { - path: '/my-route', - }, - async (context, req, res) => { - const { client } = context.core.elasticsearch; - // call as current user - const res = await client.asCurrentUser.ping(); - // call as internal user - const res2 = await client.asInternalUser.search(options); - return res.ok({ body: 'ok' }); - } -); -``` - -### Creating a custom client - -Note that the `plugins` option is now longer available on the new client. As the API is now exhaustive, adding custom -endpoints using plugins should no longer be necessary. - -The API to create custom clients did not change much: - -before: - -```ts -const customClient = coreStart.elasticsearch.legacy.createClient('my-custom-client', customConfig); -// do something with the client, such as -await customClient.callAsInternalUser('ping'); -// custom client are closable -customClient.close(); -``` - -after: - -```ts -const customClient = coreStart.elasticsearch.createClient('my-custom-client', customConfig); -// do something with the client, such as -await customClient.asInternalUser.ping(); -// custom client are closable -customClient.close(); -``` - -If, for any reasons, one still needs to reach an endpoint not listed on the client API, using `request.transport` -is still possible: - -```ts -const { body } = await client.asCurrentUser.transport.request({ - method: 'get', - path: '/my-custom-endpoint', - body: { my: 'payload'}, - querystring: { param: 'foo' } -}) -``` - -Remark: the new client creation API is now only available from the `start` contract of the elasticsearch service. diff --git a/src/core/README.md b/src/core/README.md index 87c42d9c6dab..e195bf30c054 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -8,7 +8,7 @@ Core Plugin API Documentation: - [Core Server API](/docs/development/core/server/kibana-plugin-core-server.md) - [Conventions for Plugins](./CONVENTIONS.md) - [Testing Kibana Plugins](./TESTING.md) - - [Migration guide for porting existing plugins](./MIGRATION.md) + - [Kibana Platform Plugin API](./docs/developer/architecture/kibana-platform-plugin-api.asciidoc ) Internal Documentation: - [Saved Objects Migrations](./server/saved_objects/migrations/README.md) From 0bd920150659e54e8e4c8665d5a34f55633964e5 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 24 Nov 2020 09:56:56 +0100 Subject: [PATCH 07/89] [Lens] Make incomplete switches possible (#83519) --- .../pie_visualization/suggestions.test.ts | 123 ++++++++++++------ .../public/pie_visualization/suggestions.ts | 32 +++-- .../xy_visualization/xy_suggestions.test.ts | 51 +++++--- .../public/xy_visualization/xy_suggestions.ts | 72 +++++----- 4 files changed, 178 insertions(+), 100 deletions(-) diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index 3097c4066313..c0393a7e4886 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -7,6 +7,7 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { DataType } from '../types'; import { suggestions } from './suggestions'; +import { PieVisualizationState } from './types'; describe('suggestions', () => { describe('pie', () => { @@ -82,7 +83,7 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject any date operations', () => { + it('should reject date operations', () => { expect( suggestions({ table: { @@ -111,7 +112,7 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject any histogram operations', () => { + it('should reject histogram operations', () => { expect( suggestions({ table: { @@ -140,7 +141,7 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject when there are no buckets', () => { + it('should reject when there are too many buckets', () => { expect( suggestions({ table: { @@ -148,28 +149,24 @@ describe('suggestions', () => { isMultiRow: true, columns: [ { - columnId: 'c', - operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, }, - ], - changeType: 'initial', - }, - state: undefined, - keptLayerIds: ['first'], - }) - ).toHaveLength(0); - }); - - it('should reject when there are no metrics', () => { - expect( - suggestions({ - table: { - layerId: 'first', - isMultiRow: true, - columns: [ { columnId: 'c', - operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: true }, + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'd', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, }, ], changeType: 'initial', @@ -180,7 +177,7 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject when there are too many buckets', () => { + it('should reject when there are too many metrics', () => { expect( suggestions({ table: { @@ -201,7 +198,7 @@ describe('suggestions', () => { }, { columnId: 'd', - operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, }, { columnId: 'e', @@ -216,42 +213,86 @@ describe('suggestions', () => { ).toHaveLength(0); }); - it('should reject when there are too many metrics', () => { + it('should reject if there are no buckets and it is not a specific chart type switch', () => { expect( suggestions({ table: { layerId: 'first', isMultiRow: true, columns: [ - { - columnId: 'a', - operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, - }, - { - columnId: 'b', - operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, - }, { columnId: 'c', - operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, - }, - { - columnId: 'd', - operation: { label: 'Avg', dataType: 'number' as DataType, isBucketed: false }, + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, }, + ], + changeType: 'initial', + }, + state: {} as PieVisualizationState, + keptLayerIds: ['first'], + }) + ).toHaveLength(0); + }); + + it('should reject if there are no metrics and it is not a specific chart type switch', () => { + expect( + suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ { - columnId: 'e', - operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: true }, }, ], changeType: 'initial', }, - state: undefined, + state: {} as PieVisualizationState, keptLayerIds: ['first'], }) ).toHaveLength(0); }); + it('should hide suggestions when there are no buckets', () => { + const currentSuggestions = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }); + expect(currentSuggestions).toHaveLength(3); + expect(currentSuggestions.every((s) => s.hide)).toEqual(true); + }); + + it('should hide suggestions when there are no metrics', () => { + const currentSuggestions = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'c', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: true }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + }); + expect(currentSuggestions).toHaveLength(3); + expect(currentSuggestions.every((s) => s.hide)).toEqual(true); + }); + it('should suggest a donut chart as initial state when only one bucket', () => { const results = suggestions({ table: { diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 497fb2e7de84..5eacb118b27d 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -24,6 +24,7 @@ export function suggestions({ state, keptLayerIds, mainPalette, + subVisualizationId, }: SuggestionRequest): Array< VisualizationSuggestion > { @@ -33,11 +34,17 @@ export function suggestions({ const [groups, metrics] = partition(table.columns, (col) => col.operation.isBucketed); - if ( - groups.length === 0 || - metrics.length !== 1 || - groups.length > Math.max(MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS) - ) { + if (metrics.length > 1 || groups.length > Math.max(MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS)) { + return []; + } + + const incompleteConfiguration = metrics.length === 0 || groups.length === 0; + const metricColumnId = metrics.length > 0 ? metrics[0].columnId : undefined; + + if (incompleteConfiguration && state && !subVisualizationId) { + // reject incomplete configurations if the sub visualization isn't specifically requested + // this allows to switch chart types via switcher with incomplete configurations, but won't + // cause incomplete suggestions getting auto applied on dropped fields return []; } @@ -65,12 +72,12 @@ export function suggestions({ ...state.layers[0], layerId: table.layerId, groups: groups.map((col) => col.columnId), - metric: metrics[0].columnId, + metric: metricColumnId, } : { layerId: table.layerId, groups: groups.map((col) => col.columnId), - metric: metrics[0].columnId, + metric: metricColumnId, numberDisplay: 'percent', categoryDisplay: 'default', legendDisplay: 'default', @@ -117,7 +124,7 @@ export function suggestions({ ...state.layers[0], layerId: table.layerId, groups: groups.map((col) => col.columnId), - metric: metrics[0].columnId, + metric: metricColumnId, categoryDisplay: state.layers[0].categoryDisplay === 'inside' ? 'default' @@ -126,7 +133,7 @@ export function suggestions({ : { layerId: table.layerId, groups: groups.map((col) => col.columnId), - metric: metrics[0].columnId, + metric: metricColumnId, numberDisplay: 'percent', categoryDisplay: 'default', legendDisplay: 'default', @@ -140,5 +147,10 @@ export function suggestions({ }); } - return [...results].sort((a, b) => a.score - b.score); + return [...results] + .sort((a, b) => a.score - b.score) + .map((suggestion) => ({ + ...suggestion, + hide: incompleteConfiguration || suggestion.hide, + })); } diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index d214554de340..6ecba83d01c6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -5,12 +5,7 @@ */ import { getSuggestions } from './xy_suggestions'; -import { - TableSuggestionColumn, - VisualizationSuggestion, - DataType, - TableSuggestion, -} from '../types'; +import { TableSuggestionColumn, VisualizationSuggestion, TableSuggestion } from '../types'; import { State, XYState, visualizationTypes } from './types'; import { generateId } from '../id_generator'; import { getXyVisualization } from './xy_visualization'; @@ -89,12 +84,7 @@ describe('xy_suggestions', () => { jest.resetAllMocks(); }); - test('ignores invalid combinations', () => { - const unknownCol = () => { - const str = strCol('foo'); - return { ...str, operation: { ...str.operation, dataType: 'wonkies' as DataType } }; - }; - + test('partially maps invalid combinations, but hides them', () => { expect( ([ { @@ -111,19 +101,41 @@ describe('xy_suggestions', () => { }, { isMultiRow: false, - columns: [strCol('foo'), numCol('bar')], + columns: [numCol('bar')], layerId: 'first', changeType: 'unchanged', }, + ] as TableSuggestion[]).map((table) => { + const suggestions = getSuggestions({ table, keptLayerIds: [] }); + expect(suggestions.every((suggestion) => suggestion.hide)).toEqual(true); + expect(suggestions).toHaveLength(10); + }) + ); + }); + + test('rejects incomplete configurations if there is a state already but no sub visualization id', () => { + expect( + ([ { isMultiRow: true, - columns: [unknownCol(), numCol('bar')], + columns: [dateCol('a')], layerId: 'first', - changeType: 'unchanged', + changeType: 'reduced', + }, + { + isMultiRow: false, + columns: [numCol('bar')], + layerId: 'first', + changeType: 'reduced', }, - ] as TableSuggestion[]).map((table) => - expect(getSuggestions({ table, keptLayerIds: [] })).toEqual([]) - ) + ] as TableSuggestion[]).map((table) => { + const suggestions = getSuggestions({ + table, + keptLayerIds: [], + state: {} as XYState, + }); + expect(suggestions).toHaveLength(0); + }) ); }); @@ -915,8 +927,9 @@ describe('xy_suggestions', () => { Object { "seriesType": "bar_stacked", "splitAccessor": undefined, - "x": "quantity", + "x": undefined, "y": Array [ + "quantity", "price", ], }, diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 7bbb03957730..a308a0c29302 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -39,33 +39,34 @@ export function getSuggestions({ subVisualizationId, mainPalette, }: SuggestionRequest): Array> { - if ( - // We only render line charts for multi-row queries. We require at least - // two columns: one for x and at least one for y, and y columns must be numeric. - // We reject any datasource suggestions which have a column of an unknown type. + const incompleteTable = !table.isMultiRow || table.columns.length <= 1 || table.columns.every((col) => col.operation.dataType !== 'number') || - table.columns.some((col) => !columnSortOrder.hasOwnProperty(col.operation.dataType)) - ) { - if (table.changeType === 'unchanged' && state) { - // this isn't a table we would switch to, but we have a state already. In this case, just use the current state for all series types - return visualizationTypes.map((visType) => { - const seriesType = visType.id as SeriesType; - return { - seriesType, - score: 0, - state: { - ...state, - preferredSeriesType: seriesType, - layers: state.layers.map((layer) => ({ ...layer, seriesType })), - }, - previewIcon: getIconForSeries(seriesType), - title: visType.label, - hide: true, - }; - }); - } + table.columns.some((col) => !columnSortOrder.hasOwnProperty(col.operation.dataType)); + if (incompleteTable && table.changeType === 'unchanged' && state) { + // this isn't a table we would switch to, but we have a state already. In this case, just use the current state for all series types + return visualizationTypes.map((visType) => { + const seriesType = visType.id as SeriesType; + return { + seriesType, + score: 0, + state: { + ...state, + preferredSeriesType: seriesType, + layers: state.layers.map((layer) => ({ ...layer, seriesType })), + }, + previewIcon: getIconForSeries(seriesType), + title: visType.label, + hide: true, + }; + }); + } + + if (incompleteTable && state && !subVisualizationId) { + // reject incomplete configurations if the sub visualization isn't specifically requested + // this allows to switch chart types via switcher with incomplete configurations, but won't + // cause incomplete suggestions getting auto applied on dropped fields return []; } @@ -108,13 +109,16 @@ function getSuggestionForColumns( mainPalette, }); } else if (buckets.length === 0) { - const [x, ...yValues] = prioritizeColumns(values); + const [yValues, [xValue, splitBy]] = partition( + prioritizeColumns(values), + (col) => col.operation.dataType === 'number' && !col.operation.isBucketed + ); return getSuggestionsForLayer({ layerId: table.layerId, changeType: table.changeType, - xValue: x, + xValue, yValues, - splitBy: undefined, + splitBy, currentState, tableLabel: table.label, keptLayerIds, @@ -241,9 +245,13 @@ function getSuggestionsForLayer({ return visualizationTypes .map((visType) => { return { - ...buildSuggestion({ ...options, seriesType: visType.id as SeriesType }), + ...buildSuggestion({ + ...options, + seriesType: visType.id as SeriesType, + // explicitly hide everything besides stacked bars, use default hiding logic for stacked bars + hide: visType.id === 'bar_stacked' ? undefined : true, + }), title: visType.label, - hide: visType.id !== 'bar_stacked', }; }) .sort((a, b) => (a.state.preferredSeriesType === 'bar_stacked' ? -1 : 1)); @@ -541,7 +549,11 @@ function buildSuggestion({ // Only advertise very clear changes when XY chart is not active ((!currentState && changeType !== 'unchanged' && changeType !== 'extended') || // Don't advertise removing dimensions - (currentState && changeType === 'reduced')), + (currentState && changeType === 'reduced') || + // Don't advertise charts without y axis + yValues.length === 0 || + // Don't advertise charts without at least one split + (!xValue && !splitBy)), state, previewIcon: getIconForSeries(seriesType), }; From aa07f5c1d0868a7103e0dd5730776b205b6a868a Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Tue, 24 Nov 2020 10:11:58 +0100 Subject: [PATCH 08/89] [Fleet] Rename usage collection object to 'fleet'. (#83407) * Rename usage collection object to 'fleet'. * Update telemetry mapping. * Adjust naming. * Rename ingestManager -> fleet in telemetry collector --- .../fleet/server/collectors/config_collectors.ts | 2 +- .../plugins/fleet/server/collectors/register.ts | 16 ++++++++-------- x-pack/plugins/fleet/server/plugin.ts | 4 ++-- .../schema/xpack_plugins.json | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/fleet/server/collectors/config_collectors.ts b/x-pack/plugins/fleet/server/collectors/config_collectors.ts index 8fb4924a2ccf..f26e4261d573 100644 --- a/x-pack/plugins/fleet/server/collectors/config_collectors.ts +++ b/x-pack/plugins/fleet/server/collectors/config_collectors.ts @@ -6,6 +6,6 @@ import { FleetConfigType } from '..'; -export const getIsFleetEnabled = (config: FleetConfigType) => { +export const getIsAgentsEnabled = (config: FleetConfigType) => { return config.agents.enabled; }; diff --git a/x-pack/plugins/fleet/server/collectors/register.ts b/x-pack/plugins/fleet/server/collectors/register.ts index e7d95a7e8377..35517e6a7a70 100644 --- a/x-pack/plugins/fleet/server/collectors/register.ts +++ b/x-pack/plugins/fleet/server/collectors/register.ts @@ -6,19 +6,19 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { CoreSetup } from 'kibana/server'; -import { getIsFleetEnabled } from './config_collectors'; +import { getIsAgentsEnabled } from './config_collectors'; import { AgentUsage, getAgentUsage } from './agent_collectors'; import { getInternalSavedObjectsClient } from './helpers'; import { PackageUsage, getPackageUsage } from './package_collectors'; import { FleetConfigType } from '..'; interface Usage { - fleet_enabled: boolean; + agents_enabled: boolean; agents: AgentUsage; packages: PackageUsage[]; } -export function registerIngestManagerUsageCollector( +export function registerFleetUsageCollector( core: CoreSetup, config: FleetConfigType, usageCollection: UsageCollectionSetup | undefined @@ -30,19 +30,19 @@ export function registerIngestManagerUsageCollector( } // create usage collector - const ingestManagerCollector = usageCollection.makeUsageCollector({ - type: 'ingest_manager', + const fleetCollector = usageCollection.makeUsageCollector({ + type: 'fleet', isReady: () => true, fetch: async () => { const soClient = await getInternalSavedObjectsClient(core); return { - fleet_enabled: getIsFleetEnabled(config), + agents_enabled: getIsAgentsEnabled(config), agents: await getAgentUsage(soClient), packages: await getPackageUsage(soClient), }; }, schema: { - fleet_enabled: { type: 'boolean' }, + agents_enabled: { type: 'boolean' }, agents: { total: { type: 'long' }, online: { type: 'long' }, @@ -61,5 +61,5 @@ export function registerIngestManagerUsageCollector( }); // register usage collector - usageCollection.registerCollector(ingestManagerCollector); + usageCollection.registerCollector(fleetCollector); } diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 90fb34efd481..716939c28bf1 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -71,7 +71,7 @@ import { } from './services/agents'; import { CloudSetup } from '../../cloud/server'; import { agentCheckinState } from './services/agents/checkin/state'; -import { registerIngestManagerUsageCollector } from './collectors/register'; +import { registerFleetUsageCollector } from './collectors/register'; import { getInstallation } from './services/epm/packages'; export interface FleetSetupDeps { @@ -216,7 +216,7 @@ export class FleetPlugin const config = await this.config$.pipe(first()).toPromise(); // Register usage collection - registerIngestManagerUsageCollector(core, config, deps.usageCollection); + registerFleetUsageCollector(core, config, deps.usageCollection); // Always register app routes for permissions checking registerAppRoutes(router); diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 8936cdafa382..e1b5f4cb9c3a 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -1778,9 +1778,9 @@ } } }, - "ingest_manager": { + "fleet": { "properties": { - "fleet_enabled": { + "agents_enabled": { "type": "boolean" }, "agents": { From a0a6518f31da054dc8db4ba08d94c403df7c1677 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 24 Nov 2020 10:27:24 +0100 Subject: [PATCH 09/89] Update CODEOWNERS with new APM people (#84120) --- .github/CODEOWNERS | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index be2b4533e22d..834662044988 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -69,7 +69,8 @@ /x-pack/plugins/apm/ @elastic/apm-ui /x-pack/test/functional/apps/apm/ @elastic/apm-ui /src/plugins/apm_oss/ @elastic/apm-ui -/src/apm.js @watson @vigneshshanmugam +/src/apm.js @elastic/kibana-core @vigneshshanmugam +/packages/kbn-apm-config-loader/ @elastic/kibana-core @vigneshshanmugam #CC# /src/plugins/apm_oss/ @elastic/apm-ui #CC# /x-pack/plugins/observability/ @elastic/apm-ui From 7d5fb8e83aa81ec9f777a23fc39d32ec83199c5b Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 24 Nov 2020 10:44:57 +0100 Subject: [PATCH 10/89] [GS] add search syntax support (#83422) * add search syntax parsing logic * fix ts types * use type filter in providers * move search syntax logic to the searchbar * fix test plugin types * fix test plugin types again * use `onSearch` prop to disable internal component search * add tag filter support * add FTR tests * move away from CI group 7 * fix unit tests * add unit tests * remove the API test suite * Add icons to the SO results * add test for unknown type / tag * nits * ignore case for the `type` filter * Add syntax help text * remove unused import * hide icon for non-application results * add tsdoc on query utils * coerce known filter values to string Co-authored-by: Ryan Keairns --- .../public/api.mock.ts | 1 + .../saved_objects_tagging_oss/public/api.ts | 8 +- x-pack/plugins/global_search/common/types.ts | 25 ++ x-pack/plugins/global_search/public/index.ts | 2 + .../services/fetch_server_results.test.ts | 21 +- .../public/services/fetch_server_results.ts | 6 +- .../public/services/search_service.test.ts | 40 +- .../public/services/search_service.ts | 21 +- x-pack/plugins/global_search/public/types.ts | 10 +- .../global_search/server/routes/find.ts | 10 +- .../routes/integration_tests/find.test.ts | 27 +- .../server/services/search_service.test.ts | 22 +- .../server/services/search_service.ts | 23 +- x-pack/plugins/global_search/server/types.ts | 11 +- x-pack/plugins/global_search_bar/kibana.json | 2 +- .../__snapshots__/search_bar.test.tsx.snap | 2 +- .../public/components/search_bar.test.tsx | 6 +- .../public/components/search_bar.tsx | 127 +++++-- .../global_search_bar/public/plugin.tsx | 70 ++-- .../public/search_syntax/index.ts | 8 + .../search_syntax/parse_search_params.test.ts | 87 +++++ .../search_syntax/parse_search_params.ts | 59 +++ .../public/search_syntax/query_utils.test.ts | 134 +++++++ .../public/search_syntax/query_utils.ts | 79 ++++ .../public/search_syntax/types.ts | 34 ++ .../public/providers/application.test.ts | 73 +++- .../public/providers/application.ts | 9 +- .../map_object_to_result.test.ts | 2 + .../saved_objects/map_object_to_result.ts | 1 + .../providers/saved_objects/provider.test.ts | 58 ++- .../providers/saved_objects/provider.ts | 15 +- x-pack/plugins/lens/public/search_provider.ts | 7 +- .../public/ui_api/index.ts | 3 +- x-pack/test/functional/page_objects/index.ts | 2 + .../page_objects/navigational_search.ts | 95 +++++ x-pack/test/plugin_functional/config.ts | 5 + .../global_search/search_syntax/data.json | 358 ++++++++++++++++++ .../global_search/search_syntax/mappings.json | 266 +++++++++++++ .../plugins/global_search_test/kibana.json | 2 +- .../global_search_test/public/plugin.ts | 40 +- .../global_search_test/server/index.ts | 21 - .../global_search_test/server/plugin.ts | 61 --- .../global_search/global_search_api.ts | 49 --- .../global_search/global_search_bar.ts | 144 ++++++- .../global_search/global_search_providers.ts | 2 +- .../test_suites/global_search/index.ts | 6 +- 46 files changed, 1703 insertions(+), 351 deletions(-) create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/index.ts create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts create mode 100644 x-pack/plugins/global_search_bar/public/search_syntax/types.ts create mode 100644 x-pack/test/functional/page_objects/navigational_search.ts create mode 100644 x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json create mode 100644 x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json delete mode 100644 x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts delete mode 100644 x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts delete mode 100644 x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts diff --git a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts index e29922c2481c..87a3fd8f5b49 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -60,6 +60,7 @@ const createApiUiMock = (): SavedObjectsTaggingApiUiMock => { convertNameToReference: jest.fn(), parseSearchQuery: jest.fn(), getTagIdsFromReferences: jest.fn(), + getTagIdFromName: jest.fn(), updateTagsReferences: jest.fn(), }; diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index 71548cd5c7f5..81f7cc9326a7 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -84,7 +84,7 @@ export interface SavedObjectsTaggingApiUi { /** * Convert given tag name to a {@link SavedObjectsFindOptionsReference | reference } * to be used to search using the savedObjects `_find` API. Will return `undefined` - * is the given name does not match any existing tag. + * if the given name does not match any existing tag. */ convertNameToReference(tagName: string): SavedObjectsFindOptionsReference | undefined; @@ -124,6 +124,12 @@ export interface SavedObjectsTaggingApiUi { references: Array ): string[]; + /** + * Returns the id for given tag name. Will return `undefined` + * if the given name does not match any existing tag. + */ + getTagIdFromName(tagName: string): string | undefined; + /** * Returns a new references array that replace the old tag references with references to the * new given tag ids, while preserving all non-tag references. diff --git a/x-pack/plugins/global_search/common/types.ts b/x-pack/plugins/global_search/common/types.ts index a08ecaf41b21..7cc1d7ada442 100644 --- a/x-pack/plugins/global_search/common/types.ts +++ b/x-pack/plugins/global_search/common/types.ts @@ -87,3 +87,28 @@ export interface GlobalSearchBatchedResults { */ results: GlobalSearchResult[]; } + +/** + * Search parameters for the {@link GlobalSearchPluginStart.find | `find` API} + * + * @public + */ +export interface GlobalSearchFindParams { + /** + * The term to search for. Can be undefined if searching by filters. + */ + term?: string; + /** + * The types of results to search for. + */ + types?: string[]; + /** + * The tag ids to filter search by. + */ + tags?: string[]; +} + +/** + * @public + */ +export type GlobalSearchProviderFindParams = GlobalSearchFindParams; diff --git a/x-pack/plugins/global_search/public/index.ts b/x-pack/plugins/global_search/public/index.ts index 18483cea7254..0e1cbaedae78 100644 --- a/x-pack/plugins/global_search/public/index.ts +++ b/x-pack/plugins/global_search/public/index.ts @@ -25,6 +25,8 @@ export { GlobalSearchProviderResult, GlobalSearchProviderResultUrl, GlobalSearchResult, + GlobalSearchFindParams, + GlobalSearchProviderFindParams, } from '../common/types'; export { GlobalSearchPluginSetup, diff --git a/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts b/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts index f62acd08633f..4794c355a161 100644 --- a/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts +++ b/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts @@ -33,11 +33,18 @@ describe('fetchServerResults', () => { it('perform a POST request to the endpoint with valid options', () => { http.post.mockResolvedValue({ results: [] }); - fetchServerResults(http, 'some term', { preference: 'pref' }); + fetchServerResults( + http, + { term: 'some term', types: ['dashboard', 'map'] }, + { preference: 'pref' } + ); expect(http.post).toHaveBeenCalledTimes(1); expect(http.post).toHaveBeenCalledWith('/internal/global_search/find', { - body: JSON.stringify({ term: 'some term', options: { preference: 'pref' } }), + body: JSON.stringify({ + params: { term: 'some term', types: ['dashboard', 'map'] }, + options: { preference: 'pref' }, + }), }); }); @@ -47,7 +54,11 @@ describe('fetchServerResults', () => { http.post.mockResolvedValue({ results: [resultA, resultB] }); - const results = await fetchServerResults(http, 'some term', { preference: 'pref' }).toPromise(); + const results = await fetchServerResults( + http, + { term: 'some term' }, + { preference: 'pref' } + ).toPromise(); expect(http.post).toHaveBeenCalledTimes(1); expect(results).toHaveLength(2); @@ -65,7 +76,7 @@ describe('fetchServerResults', () => { getTestScheduler().run(({ expectObservable, hot }) => { http.post.mockReturnValue(hot('---(a|)', { a: { results: [] } }) as any); - const results = fetchServerResults(http, 'term', {}); + const results = fetchServerResults(http, { term: 'term' }, {}); expectObservable(results).toBe('---(a|)', { a: [], @@ -77,7 +88,7 @@ describe('fetchServerResults', () => { getTestScheduler().run(({ expectObservable, hot }) => { http.post.mockReturnValue(hot('---(a|)', { a: { results: [] } }) as any); const aborted$ = hot('-(a|)', { a: undefined }); - const results = fetchServerResults(http, 'term', { aborted$ }); + const results = fetchServerResults(http, { term: 'term' }, { aborted$ }); expectObservable(results).toBe('-|', { a: [], diff --git a/x-pack/plugins/global_search/public/services/fetch_server_results.ts b/x-pack/plugins/global_search/public/services/fetch_server_results.ts index 3c06dfab9f50..7508c8db5716 100644 --- a/x-pack/plugins/global_search/public/services/fetch_server_results.ts +++ b/x-pack/plugins/global_search/public/services/fetch_server_results.ts @@ -7,7 +7,7 @@ import { Observable, from, EMPTY } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { HttpStart } from 'src/core/public'; -import { GlobalSearchResult } from '../../common/types'; +import { GlobalSearchResult, GlobalSearchProviderFindParams } from '../../common/types'; import { GlobalSearchFindOptions } from './types'; interface ServerFetchResponse { @@ -24,7 +24,7 @@ interface ServerFetchResponse { */ export const fetchServerResults = ( http: HttpStart, - term: string, + params: GlobalSearchProviderFindParams, { preference, aborted$ }: GlobalSearchFindOptions ): Observable => { let controller: AbortController | undefined; @@ -36,7 +36,7 @@ export const fetchServerResults = ( } return from( http.post('/internal/global_search/find', { - body: JSON.stringify({ term, options: { preference } }), + body: JSON.stringify({ params, options: { preference } }), signal: controller?.signal, }) ).pipe( diff --git a/x-pack/plugins/global_search/public/services/search_service.test.ts b/x-pack/plugins/global_search/public/services/search_service.test.ts index 350547a928fe..419ad847d6c2 100644 --- a/x-pack/plugins/global_search/public/services/search_service.test.ts +++ b/x-pack/plugins/global_search/public/services/search_service.test.ts @@ -116,11 +116,14 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start(startDeps()); - find('foobar', { preference: 'pref' }); + find( + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, + { preference: 'pref' } + ); expect(provider.find).toHaveBeenCalledTimes(1); expect(provider.find).toHaveBeenCalledWith( - 'foobar', + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, expect.objectContaining({ preference: 'pref' }) ); }); @@ -129,12 +132,15 @@ describe('SearchService', () => { service.setup({ config: createConfig() }); const { find } = service.start(startDeps()); - find('foobar', { preference: 'pref' }); + find( + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, + { preference: 'pref' } + ); expect(fetchServerResultsMock).toHaveBeenCalledTimes(1); expect(fetchServerResultsMock).toHaveBeenCalledWith( httpStart, - 'foobar', + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, expect.objectContaining({ preference: 'pref', aborted$: expect.any(Object) }) ); }); @@ -148,25 +154,25 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start(startDeps()); - find('foobar', { preference: 'pref' }); + find({ term: 'foobar' }, { preference: 'pref' }); expect(getDefaultPreferenceMock).not.toHaveBeenCalled(); expect(provider.find).toHaveBeenNthCalledWith( 1, - 'foobar', + { term: 'foobar' }, expect.objectContaining({ preference: 'pref', }) ); - find('foobar', {}); + find({ term: 'foobar' }, {}); expect(getDefaultPreferenceMock).toHaveBeenCalledTimes(1); expect(provider.find).toHaveBeenNthCalledWith( 2, - 'foobar', + { term: 'foobar' }, expect.objectContaining({ preference: 'default_pref', }) @@ -186,7 +192,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a-b-|', { a: expectedBatch('1'), @@ -207,7 +213,7 @@ describe('SearchService', () => { fetchServerResultsMock.mockReturnValue(serverResults); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a-b-|', { a: expectedBatch('1'), @@ -242,7 +248,7 @@ describe('SearchService', () => { ); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('ab-cd-|', { a: expectedBatch('A1', 'A2'), @@ -276,7 +282,7 @@ describe('SearchService', () => { ); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a-b--(c|)', { a: expectedBatch('P1'), @@ -301,7 +307,7 @@ describe('SearchService', () => { const aborted$ = hot('----a--|', { a: undefined }); const { find } = service.start(startDeps()); - const results = find('foo', { aborted$ }); + const results = find({ term: 'foobar' }, { aborted$ }); expectObservable(results).toBe('--a-|', { a: expectedBatch('1'), @@ -323,7 +329,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('a 24ms b 74ms |', { a: expectedBatch('1'), @@ -359,7 +365,7 @@ describe('SearchService', () => { ); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe('ab-(c|)', { a: expectedBatch('A1', 'A2'), @@ -392,7 +398,7 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start(startDeps()); - const batch = await find('foo', {}).pipe(take(1)).toPromise(); + const batch = await find({ term: 'foobar' }, {}).pipe(take(1)).toPromise(); expect(batch.results).toHaveLength(2); expect(batch.results[0]).toEqual({ @@ -420,7 +426,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start(startDeps()); - const results = find('foo', {}); + const results = find({ term: 'foobar' }, {}); expectObservable(results).toBe( '#', diff --git a/x-pack/plugins/global_search/public/services/search_service.ts b/x-pack/plugins/global_search/public/services/search_service.ts index 62b347d92586..64bd2fd6c930 100644 --- a/x-pack/plugins/global_search/public/services/search_service.ts +++ b/x-pack/plugins/global_search/public/services/search_service.ts @@ -9,7 +9,11 @@ import { map, takeUntil } from 'rxjs/operators'; import { duration } from 'moment'; import { i18n } from '@kbn/i18n'; import { HttpStart } from 'src/core/public'; -import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; +import { + GlobalSearchFindParams, + GlobalSearchProviderResult, + GlobalSearchBatchedResults, +} from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; import { defaultMaxProviderResults } from '../../common/constants'; @@ -52,7 +56,7 @@ export interface SearchServiceStart { * * @example * ```ts - * startDeps.globalSearch.find('some term').subscribe({ + * startDeps.globalSearch.find({term: 'some term'}).subscribe({ * next: ({ results }) => { * addNewResultsToList(results); * }, @@ -67,7 +71,10 @@ export interface SearchServiceStart { * Emissions from the resulting observable will only contains **new** results. It is the consumer's * responsibility to aggregate the emission and sort the results if required. */ - find(term: string, options: GlobalSearchFindOptions): Observable; + find( + params: GlobalSearchFindParams, + options: GlobalSearchFindOptions + ): Observable; } interface SetupDeps { @@ -110,11 +117,11 @@ export class SearchService { this.licenseChecker = licenseChecker; return { - find: (term, options) => this.performFind(term, options), + find: (params, options) => this.performFind(params, options), }; } - private performFind(term: string, options: GlobalSearchFindOptions) { + private performFind(params: GlobalSearchFindParams, options: GlobalSearchFindOptions) { const licenseState = this.licenseChecker!.getState(); if (!licenseState.valid) { return throwError( @@ -142,13 +149,13 @@ export class SearchService { const processResult = (result: GlobalSearchProviderResult) => processProviderResult(result, this.http!.basePath); - const serverResults$ = fetchServerResults(this.http!, term, { + const serverResults$ = fetchServerResults(this.http!, params, { preference, aborted$, }); const providersResults$ = [...this.providers.values()].map((provider) => - provider.find(term, providerOptions).pipe( + provider.find(params, providerOptions).pipe( takeInArray(this.maxProviderResults), takeUntil(aborted$), map((results) => results.map((r) => processResult(r))) diff --git a/x-pack/plugins/global_search/public/types.ts b/x-pack/plugins/global_search/public/types.ts index 42ef234504d1..2707a2fded22 100644 --- a/x-pack/plugins/global_search/public/types.ts +++ b/x-pack/plugins/global_search/public/types.ts @@ -5,7 +5,11 @@ */ import { Observable } from 'rxjs'; -import { GlobalSearchProviderFindOptions, GlobalSearchProviderResult } from '../common/types'; +import { + GlobalSearchProviderFindOptions, + GlobalSearchProviderResult, + GlobalSearchProviderFindParams, +} from '../common/types'; import { SearchServiceSetup, SearchServiceStart } from './services'; export type GlobalSearchPluginSetup = Pick; @@ -29,7 +33,7 @@ export interface GlobalSearchResultProvider { * // returning all results in a single batch * setupDeps.globalSearch.registerResultProvider({ * id: 'my_provider', - * find: (term, { aborted$, preference, maxResults }, context) => { + * find: ({ term, filters }, { aborted$, preference, maxResults }, context) => { * const resultPromise = myService.search(term, { preference, maxResults }, context.core.savedObjects.client); * return from(resultPromise).pipe(takeUntil(aborted$)); * }, @@ -37,7 +41,7 @@ export interface GlobalSearchResultProvider { * ``` */ find( - term: string, + search: GlobalSearchProviderFindParams, options: GlobalSearchProviderFindOptions ): Observable; } diff --git a/x-pack/plugins/global_search/server/routes/find.ts b/x-pack/plugins/global_search/server/routes/find.ts index a9063abda0e3..0b82a035348e 100644 --- a/x-pack/plugins/global_search/server/routes/find.ts +++ b/x-pack/plugins/global_search/server/routes/find.ts @@ -15,7 +15,11 @@ export const registerInternalFindRoute = (router: IRouter) => { path: '/internal/global_search/find', validate: { body: schema.object({ - term: schema.string(), + params: schema.object({ + term: schema.maybe(schema.string()), + types: schema.maybe(schema.arrayOf(schema.string())), + tags: schema.maybe(schema.arrayOf(schema.string())), + }), options: schema.maybe( schema.object({ preference: schema.maybe(schema.string()), @@ -25,10 +29,10 @@ export const registerInternalFindRoute = (router: IRouter) => { }, }, async (ctx, req, res) => { - const { term, options } = req.body; + const { params, options } = req.body; try { const allResults = await ctx - .globalSearch!.find(term, { ...options, aborted$: req.events.aborted$ }) + .globalSearch!.find(params, { ...options, aborted$: req.events.aborted$ }) .pipe( map((batch) => batch.results), reduce((acc, results) => [...acc, ...results]) diff --git a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts index ed28786782c3..c37bcdbf8474 100644 --- a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts +++ b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts @@ -62,7 +62,9 @@ describe('POST /internal/global_search/find', () => { await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, options: { preference: 'custom-pref', }, @@ -70,10 +72,13 @@ describe('POST /internal/global_search/find', () => { .expect(200); expect(globalSearchHandlerContext.find).toHaveBeenCalledTimes(1); - expect(globalSearchHandlerContext.find).toHaveBeenCalledWith('search', { - preference: 'custom-pref', - aborted$: expect.any(Object), - }); + expect(globalSearchHandlerContext.find).toHaveBeenCalledWith( + { term: 'search' }, + { + preference: 'custom-pref', + aborted$: expect.any(Object), + } + ); }); it('returns all the results returned from the service', async () => { @@ -84,7 +89,9 @@ describe('POST /internal/global_search/find', () => { const response = await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, }) .expect(200); @@ -101,7 +108,9 @@ describe('POST /internal/global_search/find', () => { const response = await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, }) .expect(403); @@ -119,7 +128,9 @@ describe('POST /internal/global_search/find', () => { const response = await supertest(httpSetup.server.listener) .post('/internal/global_search/find') .send({ - term: 'search', + params: { + term: 'search', + }, }) .expect(500); diff --git a/x-pack/plugins/global_search/server/services/search_service.test.ts b/x-pack/plugins/global_search/server/services/search_service.test.ts index 2460100a46db..c8d656a524e9 100644 --- a/x-pack/plugins/global_search/server/services/search_service.test.ts +++ b/x-pack/plugins/global_search/server/services/search_service.test.ts @@ -97,11 +97,15 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start({ core: coreStart, licenseChecker }); - find('foobar', { preference: 'pref' }, request); + find( + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, + { preference: 'pref' }, + request + ); expect(provider.find).toHaveBeenCalledTimes(1); expect(provider.find).toHaveBeenCalledWith( - 'foobar', + { term: 'foobar', types: ['dashboard', 'map'], tags: ['tag-id'] }, expect.objectContaining({ preference: 'pref' }), expect.objectContaining({ core: expect.any(Object) }) ); @@ -121,7 +125,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('a-b-|', { a: expectedBatch('1'), @@ -157,7 +161,7 @@ describe('SearchService', () => { ); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('ab-cd-|', { a: expectedBatch('A1', 'A2'), @@ -184,7 +188,7 @@ describe('SearchService', () => { const aborted$ = hot('----a--|', { a: undefined }); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', { aborted$ }, request); + const results = find({ term: 'foobar' }, { aborted$ }, request); expectObservable(results).toBe('--a-|', { a: expectedBatch('1'), @@ -207,7 +211,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('a 24ms b 74ms |', { a: expectedBatch('1'), @@ -244,7 +248,7 @@ describe('SearchService', () => { ); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe('ab-(c|)', { a: expectedBatch('A1', 'A2'), @@ -278,7 +282,7 @@ describe('SearchService', () => { registerResultProvider(provider); const { find } = service.start({ core: coreStart, licenseChecker }); - const batch = await find('foo', {}, request).pipe(take(1)).toPromise(); + const batch = await find({ term: 'foobar' }, {}, request).pipe(take(1)).toPromise(); expect(batch.results).toHaveLength(2); expect(batch.results[0]).toEqual({ @@ -307,7 +311,7 @@ describe('SearchService', () => { registerResultProvider(createProvider('A', providerResults)); const { find } = service.start({ core: coreStart, licenseChecker }); - const results = find('foo', {}, request); + const results = find({ term: 'foobar' }, {}, request); expectObservable(results).toBe( '#', diff --git a/x-pack/plugins/global_search/server/services/search_service.ts b/x-pack/plugins/global_search/server/services/search_service.ts index 1897a24196cf..9ea62abac704 100644 --- a/x-pack/plugins/global_search/server/services/search_service.ts +++ b/x-pack/plugins/global_search/server/services/search_service.ts @@ -8,12 +8,15 @@ import { Observable, timer, merge, throwError } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, CoreStart, IBasePath } from 'src/core/server'; -import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; +import { + GlobalSearchProviderResult, + GlobalSearchBatchedResults, + GlobalSearchFindParams, +} from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; import { defaultMaxProviderResults } from '../../common/constants'; import { ILicenseChecker } from '../../common/license_checker'; - import { processProviderResult } from '../../common/process_result'; import { GlobalSearchConfigType } from '../config'; import { getContextFactory, GlobalSearchContextFactory } from './context'; @@ -46,7 +49,7 @@ export interface SearchServiceStart { * * @example * ```ts - * startDeps.globalSearch.find('some term').subscribe({ + * startDeps.globalSearch.find({ term: 'some term' }).subscribe({ * next: ({ results }) => { * addNewResultsToList(results); * }, @@ -64,7 +67,7 @@ export interface SearchServiceStart { * from the server-side `find` API. */ find( - term: string, + params: GlobalSearchFindParams, options: GlobalSearchFindOptions, request: KibanaRequest ): Observable; @@ -115,11 +118,15 @@ export class SearchService { this.licenseChecker = licenseChecker; this.contextFactory = getContextFactory(core); return { - find: (term, options, request) => this.performFind(term, options, request), + find: (params, options, request) => this.performFind(params, options, request), }; } - private performFind(term: string, options: GlobalSearchFindOptions, request: KibanaRequest) { + private performFind( + params: GlobalSearchFindParams, + options: GlobalSearchFindOptions, + request: KibanaRequest + ) { const licenseState = this.licenseChecker!.getState(); if (!licenseState.valid) { return throwError( @@ -137,7 +144,7 @@ export class SearchService { const timeout$ = timer(this.config!.search_timeout.asMilliseconds()).pipe(map(mapToUndefined)); const aborted$ = options.aborted$ ? merge(options.aborted$, timeout$) : timeout$; - const providerOptions = { + const findOptions = { ...options, preference: options.preference ?? 'default', maxResults: this.maxProviderResults, @@ -148,7 +155,7 @@ export class SearchService { processProviderResult(result, basePath); const providersResults$ = [...this.providers.values()].map((provider) => - provider.find(term, providerOptions, context).pipe( + provider.find(params, findOptions, context).pipe( takeInArray(this.maxProviderResults), takeUntil(aborted$), map((results) => results.map((r) => processResult(r))) diff --git a/x-pack/plugins/global_search/server/types.ts b/x-pack/plugins/global_search/server/types.ts index 07d21f54d7bf..0878a965ea8c 100644 --- a/x-pack/plugins/global_search/server/types.ts +++ b/x-pack/plugins/global_search/server/types.ts @@ -16,6 +16,8 @@ import { GlobalSearchBatchedResults, GlobalSearchProviderFindOptions, GlobalSearchProviderResult, + GlobalSearchProviderFindParams, + GlobalSearchFindParams, } from '../common/types'; import { SearchServiceSetup, SearchServiceStart } from './services'; @@ -31,7 +33,10 @@ export interface RouteHandlerGlobalSearchContext { /** * See {@link SearchServiceStart.find | the find API} */ - find(term: string, options: GlobalSearchFindOptions): Observable; + find( + params: GlobalSearchFindParams, + options: GlobalSearchFindOptions + ): Observable; } /** @@ -97,7 +102,7 @@ export interface GlobalSearchResultProvider { * // returning all results in a single batch * setupDeps.globalSearch.registerResultProvider({ * id: 'my_provider', - * find: (term, { aborted$, preference, maxResults }, context) => { + * find: ({term, filters }, { aborted$, preference, maxResults }, context) => { * const resultPromise = myService.search(term, { preference, maxResults }, context.core.savedObjects.client); * return from(resultPromise).pipe(takeUntil(aborted$)); * }, @@ -105,7 +110,7 @@ export interface GlobalSearchResultProvider { * ``` */ find( - term: string, + search: GlobalSearchProviderFindParams, options: GlobalSearchProviderFindOptions, context: GlobalSearchProviderContext ): Observable; diff --git a/x-pack/plugins/global_search_bar/kibana.json b/x-pack/plugins/global_search_bar/kibana.json index bf0ae83a0d86..85e091fe1aba 100644 --- a/x-pack/plugins/global_search_bar/kibana.json +++ b/x-pack/plugins/global_search_bar/kibana.json @@ -5,6 +5,6 @@ "server": false, "ui": true, "requiredPlugins": ["globalSearch"], - "optionalPlugins": ["usageCollection"], + "optionalPlugins": ["usageCollection", "savedObjectsTagging"], "configPath": ["xpack", "global_search_bar"] } diff --git a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap index bf7eacd2b52a..de45d8ea5dfa 100644 --- a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap +++ b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap @@ -36,7 +36,7 @@ exports[`SearchBar supports keyboard shortcuts 1`] = ` aria-label="Filter options" autocomplete="off" class="euiFieldSearch euiFieldSearch--fullWidth euiFieldSearch--compressed euiSelectableSearch euiSelectableTemplateSitewide__search" - data-test-subj="header-search" + data-test-subj="nav-search-input" placeholder="Search Elastic" type="search" value="" diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx index a3e2d66eabe5..5ba00c293d21 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -54,7 +54,7 @@ describe('SearchBar', () => { }); const triggerFocus = () => { - component.find('input[data-test-subj="header-search"]').simulate('focus'); + component.find('input[data-test-subj="nav-search-input"]').simulate('focus'); }; const update = () => { @@ -100,7 +100,7 @@ describe('SearchBar', () => { update(); expect(searchService.find).toHaveBeenCalledTimes(1); - expect(searchService.find).toHaveBeenCalledWith('', {}); + expect(searchService.find).toHaveBeenCalledWith({}, {}); expect(getDisplayedOptionsTitle()).toMatchSnapshot(); await simulateTypeChar('d'); @@ -108,7 +108,7 @@ describe('SearchBar', () => { expect(getDisplayedOptionsTitle()).toMatchSnapshot(); expect(searchService.find).toHaveBeenCalledTimes(2); - expect(searchService.find).toHaveBeenCalledWith('d', {}); + expect(searchService.find).toHaveBeenCalledWith({ term: 'd' }, {}); }); it('supports keyboard shortcuts', () => { diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index adc55329962e..3746e636066a 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -5,7 +5,7 @@ */ import { - EuiBadge, + EuiCode, EuiFlexGroup, EuiFlexItem, EuiHeaderSectionItemButton, @@ -25,11 +25,18 @@ import useDebounce from 'react-use/lib/useDebounce'; import useEvent from 'react-use/lib/useEvent'; import useMountedState from 'react-use/lib/useMountedState'; import { Subscription } from 'rxjs'; -import { GlobalSearchPluginStart, GlobalSearchResult } from '../../../global_search/public'; +import { + GlobalSearchPluginStart, + GlobalSearchResult, + GlobalSearchFindParams, +} from '../../../global_search/public'; +import { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public'; +import { parseSearchParams } from '../search_syntax'; interface Props { globalSearch: GlobalSearchPluginStart['find']; navigateToUrl: ApplicationStart['navigateToUrl']; + taggingApi?: SavedObjectTaggingPluginStart; trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void; basePathUrl: string; darkMode: boolean; @@ -64,17 +71,17 @@ const sortByTitle = (a: GlobalSearchResult, b: GlobalSearchResult): number => { const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewideOption => { const { id, title, url, icon, type, meta } = result; + // only displaying icons for applications + const useIcon = type === 'application'; const option: EuiSelectableTemplateSitewideOption = { key: id, label: title, url, type, + icon: { type: useIcon && icon ? icon : 'empty' }, + 'data-test-subj': `nav-search-option`, }; - if (icon) { - option.icon = { type: icon }; - } - if (type === 'application') { option.meta = [{ text: meta?.categoryLabel as string }]; } else { @@ -86,6 +93,7 @@ const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewi export function SearchBar({ globalSearch, + taggingApi, navigateToUrl, trackUiMetric, basePathUrl, @@ -119,8 +127,24 @@ export function SearchBar({ } let arr: GlobalSearchResult[] = []; - if (searchValue.length !== 0) trackUiMetric(METRIC_TYPE.COUNT, 'search_request'); - searchSubscription.current = globalSearch(searchValue, {}).subscribe({ + if (searchValue.length !== 0) { + trackUiMetric(METRIC_TYPE.COUNT, 'search_request'); + } + + const rawParams = parseSearchParams(searchValue); + const tagIds = + taggingApi && rawParams.filters.tags + ? rawParams.filters.tags.map( + (tagName) => taggingApi.ui.getTagIdFromName(tagName) ?? '__unknown__' + ) + : undefined; + const searchParams: GlobalSearchFindParams = { + term: rawParams.term, + types: rawParams.filters.types, + tags: tagIds, + }; + + searchSubscription.current = globalSearch(searchParams, {}).subscribe({ next: ({ results }) => { if (searchValue.length > 0) { arr = [...results, ...arr].sort(sortByScore); @@ -197,7 +221,7 @@ export function SearchBar({ }; const emptyMessage = ( - + } searchProps={{ + onSearch: () => undefined, onKeyUpCapture: (e: React.KeyboardEvent) => setSearchValue(e.currentTarget.value), - 'data-test-subj': 'header-search', + 'data-test-subj': 'nav-search-input', inputRef: setSearchRef, compressed: true, placeholder: i18n.translate('xpack.globalSearchBar.searchBar.placeholder', { @@ -256,6 +281,8 @@ export function SearchBar({ }, }} popoverProps={{ + 'data-test-subj': 'nav-search-popover', + panelClassName: 'navSearch__panel', repositionOnScroll: true, buttonRef: setButtonRef, }} @@ -265,42 +292,58 @@ export function SearchBar({ - - - - ), - commandDescription: ( - - - {isMac ? ( - - ) : ( - - )} - - - ), - }} - /> + +

+ +   + type:  + +   + tag: +

+
+ +

+ + ), + commandDescription: ( + + {isMac ? ( + + ) : ( + + )} + + ), + }} + /> +

+
} diff --git a/x-pack/plugins/global_search_bar/public/plugin.tsx b/x-pack/plugins/global_search_bar/public/plugin.tsx index 14ac0935467d..81951843ee8b 100644 --- a/x-pack/plugins/global_search_bar/public/plugin.tsx +++ b/x-pack/plugins/global_search_bar/public/plugin.tsx @@ -4,19 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; +import ReactDOM from 'react-dom'; import { UiStatsMetricType } from '@kbn/analytics'; import { I18nProvider } from '@kbn/i18n/react'; import { ApplicationStart } from 'kibana/public'; -import React from 'react'; -import ReactDOM from 'react-dom'; import { CoreStart, Plugin } from 'src/core/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { GlobalSearchPluginStart } from '../../global_search/public'; -import { SearchBar } from '../public/components/search_bar'; +import { SavedObjectTaggingPluginStart } from '../../saved_objects_tagging/public'; +import { SearchBar } from './components/search_bar'; export interface GlobalSearchBarPluginStartDeps { globalSearch: GlobalSearchPluginStart; - usageCollection: UsageCollectionSetup; + savedObjectsTagging?: SavedObjectTaggingPluginStart; + usageCollection?: UsageCollectionSetup; } export class GlobalSearchBarPlugin implements Plugin<{}, {}> { @@ -24,49 +26,61 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> { return {}; } - public start(core: CoreStart, { globalSearch, usageCollection }: GlobalSearchBarPluginStartDeps) { - let trackUiMetric = (metricType: UiStatsMetricType, eventName: string | string[]) => {}; - - if (usageCollection) { - trackUiMetric = usageCollection.reportUiStats.bind(usageCollection, 'global_search_bar'); - } + public start( + core: CoreStart, + { globalSearch, savedObjectsTagging, usageCollection }: GlobalSearchBarPluginStartDeps + ) { + const trackUiMetric = usageCollection + ? usageCollection.reportUiStats.bind(usageCollection, 'global_search_bar') + : (metricType: UiStatsMetricType, eventName: string | string[]) => {}; core.chrome.navControls.registerCenter({ order: 1000, - mount: (target) => - this.mount( - target, + mount: (container) => + this.mount({ + container, globalSearch, - core.application.navigateToUrl, - core.http.basePath.prepend('/plugins/globalSearchBar/assets/'), - core.uiSettings.get('theme:darkMode'), - trackUiMetric - ), + savedObjectsTagging, + navigateToUrl: core.application.navigateToUrl, + basePathUrl: core.http.basePath.prepend('/plugins/globalSearchBar/assets/'), + darkMode: core.uiSettings.get('theme:darkMode'), + trackUiMetric, + }), }); return {}; } - private mount( - targetDomElement: HTMLElement, - globalSearch: GlobalSearchPluginStart, - navigateToUrl: ApplicationStart['navigateToUrl'], - basePathUrl: string, - darkMode: boolean, - trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void - ) { + private mount({ + container, + globalSearch, + savedObjectsTagging, + navigateToUrl, + basePathUrl, + darkMode, + trackUiMetric, + }: { + container: HTMLElement; + globalSearch: GlobalSearchPluginStart; + savedObjectsTagging?: SavedObjectTaggingPluginStart; + navigateToUrl: ApplicationStart['navigateToUrl']; + basePathUrl: string; + darkMode: boolean; + trackUiMetric: (metricType: UiStatsMetricType, eventName: string | string[]) => void; + }) { ReactDOM.render( , - targetDomElement + container ); - return () => ReactDOM.unmountComponentAtNode(targetDomElement); + return () => ReactDOM.unmountComponentAtNode(container); } } diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/index.ts b/x-pack/plugins/global_search_bar/public/search_syntax/index.ts new file mode 100644 index 000000000000..01c52e468af3 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/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 { parseSearchParams } from './parse_search_params'; +export { ParsedSearchParams, FilterValues, FilterValueType } from './types'; diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts new file mode 100644 index 000000000000..3b00389b8605 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parseSearchParams } from './parse_search_params'; + +describe('parseSearchParams', () => { + it('returns the correct term', () => { + const searchParams = parseSearchParams('tag:(my-tag OR other-tag) hello'); + expect(searchParams.term).toEqual('hello'); + }); + + it('returns the raw query as `term` in case of parsing error', () => { + const searchParams = parseSearchParams('tag:((()^invalid'); + expect(searchParams).toEqual({ + term: 'tag:((()^invalid', + filters: { + unknowns: {}, + }, + }); + }); + + it('returns `undefined` term if query only contains field clauses', () => { + const searchParams = parseSearchParams('tag:(my-tag OR other-tag)'); + expect(searchParams.term).toBeUndefined(); + }); + + it('returns correct filters when no field clause is defined', () => { + const searchParams = parseSearchParams('hello'); + expect(searchParams.filters).toEqual({ + tags: undefined, + types: undefined, + unknowns: {}, + }); + }); + + it('returns correct filters when field clauses are present', () => { + const searchParams = parseSearchParams('tag:foo type:bar hello tag:dolly'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['foo', 'dolly'], + types: ['bar'], + unknowns: {}, + }, + }); + }); + + it('handles unknowns field clauses', () => { + const searchParams = parseSearchParams('tag:foo unknown:bar hello'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['foo'], + unknowns: { + unknown: ['bar'], + }, + }, + }); + }); + + it('handles aliases field clauses', () => { + const searchParams = parseSearchParams('tag:foo tags:bar type:dash types:board hello'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['foo', 'bar'], + types: ['dash', 'board'], + unknowns: {}, + }, + }); + }); + + it('converts boolean and number values to string for known filters', () => { + const searchParams = parseSearchParams('tag:42 tags:true type:69 types:false hello'); + expect(searchParams).toEqual({ + term: 'hello', + filters: { + tags: ['42', 'true'], + types: ['69', 'false'], + unknowns: {}, + }, + }); + }); +}); diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts new file mode 100644 index 000000000000..83117ddfb507 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/parse_search_params.ts @@ -0,0 +1,59 @@ +/* + * 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 '@elastic/eui'; +import { getSearchTerm, getFieldValueMap, applyAliases } from './query_utils'; +import { FilterValues, ParsedSearchParams } from './types'; + +const knownFilters = ['tag', 'type']; + +const aliasMap = { + tag: ['tags'], + type: ['types'], +}; + +export const parseSearchParams = (term: string): ParsedSearchParams => { + let query: Query; + + try { + query = Query.parse(term); + } catch (e) { + // if the query fails to parse, we just perform the search against the raw search term. + return { + term, + filters: { + unknowns: {}, + }, + }; + } + + const searchTerm = getSearchTerm(query); + const filterValues = applyAliases(getFieldValueMap(query), aliasMap); + + const unknownFilters = [...filterValues.entries()] + .filter(([key]) => !knownFilters.includes(key)) + .reduce((unknowns, [key, value]) => { + return { + ...unknowns, + [key]: value, + }; + }, {} as Record); + + const tags = filterValues.get('tag'); + const types = filterValues.get('type'); + + return { + term: searchTerm, + filters: { + tags: tags ? valuesToString(tags) : undefined, + types: types ? valuesToString(types) : undefined, + unknowns: unknownFilters, + }, + }; +}; + +const valuesToString = (raw: FilterValues): FilterValues => + raw.map((value) => String(value)); diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts new file mode 100644 index 000000000000..c04f5dddd34a --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Query } from '@elastic/eui'; +import { getSearchTerm, getFieldValueMap, applyAliases } from './query_utils'; +import { FilterValues } from './types'; + +describe('getSearchTerm', () => { + const searchTerm = (raw: string) => getSearchTerm(Query.parse(raw)); + + it('returns the search term when no field is present', () => { + expect(searchTerm('some plain query')).toEqual('some plain query'); + }); + + it('remove leading and trailing spaces', () => { + expect(searchTerm(' hello dolly ')).toEqual('hello dolly'); + }); + + it('remove duplicate whitespaces', () => { + expect(searchTerm(' foo bar ')).toEqual('foo bar'); + }); + + it('omits field terms', () => { + expect(searchTerm('some tag:foo query type:dashboard')).toEqual('some query'); + expect(searchTerm('tag:foo another query type:(dashboard OR vis)')).toEqual('another query'); + }); + + it('remove duplicate whitespaces when using field terms', () => { + expect(searchTerm(' over tag:foo 9000 ')).toEqual('over 9000'); + }); +}); + +describe('getFieldValueMap', () => { + const fieldValueMap = (raw: string) => getFieldValueMap(Query.parse(raw)); + + it('parses single value field term', () => { + const result = fieldValueMap('tag:foo'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo']); + }); + + it('parses multi-value field term', () => { + const result = fieldValueMap('tag:(foo OR bar)'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo', 'bar']); + }); + + it('parses multiple single value field terms', () => { + const result = fieldValueMap('tag:foo tag:bar'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo', 'bar']); + }); + + it('parses boolean field terms', () => { + const result = fieldValueMap('tag:true tag:false'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual([true, false]); + }); + + it('parses numeric field terms', () => { + const result = fieldValueMap('tag:42 tag:9000'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual([42, 9000]); + }); + + it('parses multiple mixed single/multi value field terms', () => { + const result = fieldValueMap('tag:foo tag:(bar OR hello) tag:dolly'); + + expect(result.size).toBe(1); + expect(result.get('tag')).toEqual(['foo', 'bar', 'hello', 'dolly']); + }); + + it('parses distinct field terms', () => { + const result = fieldValueMap('tag:foo type:dashboard tag:dolly type:(config OR map) foo:bar'); + + expect(result.size).toBe(3); + expect(result.get('tag')).toEqual(['foo', 'dolly']); + expect(result.get('type')).toEqual(['dashboard', 'config', 'map']); + expect(result.get('foo')).toEqual(['bar']); + }); + + it('ignore the search terms', () => { + const result = fieldValueMap('tag:foo some type:dashboard query foo:bar'); + + expect(result.size).toBe(3); + expect(result.get('tag')).toEqual(['foo']); + expect(result.get('type')).toEqual(['dashboard']); + expect(result.get('foo')).toEqual(['bar']); + }); +}); + +describe('applyAliases', () => { + const getValueMap = (entries: Record) => + new Map([...Object.entries(entries)]); + + it('returns the map unchanged when no aliases are used', () => { + const result = applyAliases( + getValueMap({ + tag: ['tag-1', 'tag-2'], + type: ['dashboard'], + }), + {} + ); + + expect(result.size).toEqual(2); + expect(result.get('tag')).toEqual(['tag-1', 'tag-2']); + expect(result.get('type')).toEqual(['dashboard']); + }); + + it('apply the aliases', () => { + const result = applyAliases( + getValueMap({ + tag: ['tag-1'], + tags: ['tag-2', 'tag-3'], + type: ['dashboard'], + }), + { + tag: ['tags'], + } + ); + + expect(result.size).toEqual(2); + expect(result.get('tag')).toEqual(['tag-1', 'tag-2', 'tag-3']); + expect(result.get('type')).toEqual(['dashboard']); + }); +}); diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts new file mode 100644 index 000000000000..93fdd943a202 --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/query_utils.ts @@ -0,0 +1,79 @@ +/* + * 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 '@elastic/eui'; +import { FilterValues } from './types'; + +/** + * Return a name->values map for all the field clauses of given query. + * + * @example + * ``` + * getFieldValueMap(Query.parse('foo:bar foo:baz hello:dolly term')); + * >> { foo: ['bar', 'baz'], hello: ['dolly] } + * ``` + */ +export const getFieldValueMap = (query: Query) => { + const fieldMap = new Map(); + + query.ast.clauses.forEach((clause) => { + if (clause.type === 'field') { + const { field, value } = clause; + fieldMap.set(field, [ + ...(fieldMap.get(field) ?? []), + ...((Array.isArray(value) ? value : [value]) as FilterValues), + ]); + } + }); + + return fieldMap; +}; + +/** + * Aggregate all term clauses from given query and concatenate them. + */ +export const getSearchTerm = (query: Query): string | undefined => { + let term: string | undefined; + if (query.ast.getTermClauses().length) { + term = query.ast + .getTermClauses() + .map((clause) => clause.value) + .join(' ') + .replace(/\s{2,}/g, ' ') + .trim(); + } + return term?.length ? term : undefined; +}; + +/** + * Apply given alias map to the value map, concatenating the aliases values to the alias target, and removing + * the alias entry. Any non-aliased entries will remain unchanged. + * + * @example + * ``` + * applyAliases({ field: ['foo'], alias: ['bar'], hello: ['dolly'] }, { field: ['alias']}); + * >> { field: ['foo', 'bar'], hello: ['dolly'] } + * ``` + */ +export const applyAliases = ( + valueMap: Map, + aliasesMap: Record +): Map => { + const reverseLookup: Record = {}; + Object.entries(aliasesMap).forEach(([canonical, aliases]) => { + aliases.forEach((alias) => { + reverseLookup[alias] = canonical; + }); + }); + + const resultMap = new Map(); + valueMap.forEach((values, field) => { + const targetKey = reverseLookup[field] ?? field; + resultMap.set(targetKey, [...(resultMap.get(targetKey) ?? []), ...values]); + }); + + return resultMap; +}; diff --git a/x-pack/plugins/global_search_bar/public/search_syntax/types.ts b/x-pack/plugins/global_search_bar/public/search_syntax/types.ts new file mode 100644 index 000000000000..8df025a478bc --- /dev/null +++ b/x-pack/plugins/global_search_bar/public/search_syntax/types.ts @@ -0,0 +1,34 @@ +/* + * 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 type FilterValueType = string | boolean | number; + +export type FilterValues = ValueType[]; + +export interface ParsedSearchParams { + /** + * The parsed search term. + * Can be undefined if the query was only composed of field terms. + */ + term?: string; + /** + * The filters extracted from the field terms. + */ + filters: { + /** + * Aggregation of `tag` and `tags` field clauses + */ + tags?: FilterValues; + /** + * Aggregation of `type` and `types` field clauses + */ + types?: FilterValues; + /** + * All unknown field clauses + */ + unknowns: Record; + }; +} diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.ts index 8acbda5e0a6d..2831550da00d 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.test.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.ts @@ -61,6 +61,10 @@ describe('applicationResultProvider', () => { getAppResultsMock.mockReturnValue([]); }); + afterEach(() => { + getAppResultsMock.mockReset(); + }); + it('has the correct id', () => { const provider = createApplicationResultProvider(Promise.resolve(application)); expect(provider.id).toBe('application'); @@ -76,7 +80,7 @@ describe('applicationResultProvider', () => { ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledTimes(1); expect(getAppResultsMock).toHaveBeenCalledWith('term', [ @@ -86,6 +90,59 @@ describe('applicationResultProvider', () => { ]); }); + it('calls `getAppResults` when filtering by type with `application` included', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + await provider + .find({ term: 'term', types: ['dashboard', 'application'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledTimes(1); + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1'), expectApp('app2')]); + }); + + it('does not call `getAppResults` and return no results when filtering by type with `application` not included', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const results = await provider + .find({ term: 'term', types: ['dashboard', 'map'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); + + it('does not call `getAppResults` and returns no results when filtering by tag', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1' }), + createApp({ id: 'app2', title: 'App 2' }), + createApp({ id: 'app3', title: 'App 3' }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + + const results = await provider + .find({ term: 'term', tags: ['some-tag-id'] }, defaultOption) + .toPromise(); + + expect(getAppResultsMock).not.toHaveBeenCalled(); + expect(results).toEqual([]); + }); + it('ignores inaccessible apps', async () => { application.applications$ = of( createAppMap([ @@ -94,7 +151,7 @@ describe('applicationResultProvider', () => { ]) ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); }); @@ -108,7 +165,7 @@ describe('applicationResultProvider', () => { ]) ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); }); @@ -122,7 +179,7 @@ describe('applicationResultProvider', () => { ); const provider = createApplicationResultProvider(Promise.resolve(application)); - await provider.find('term', defaultOption).toPromise(); + await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); }); @@ -136,7 +193,7 @@ describe('applicationResultProvider', () => { ]); const provider = createApplicationResultProvider(Promise.resolve(application)); - const results = await provider.find('term', defaultOption).toPromise(); + const results = await provider.find({ term: 'term' }, defaultOption).toPromise(); expect(results).toEqual([ expectResult('r100'), @@ -160,7 +217,7 @@ describe('applicationResultProvider', () => { ...defaultOption, maxResults: 2, }; - const results = await provider.find('term', options).toPromise(); + const results = await provider.find({ term: 'term' }, options).toPromise(); expect(results).toEqual([expectResult('r100'), expectResult('r75')]); }); @@ -184,7 +241,7 @@ describe('applicationResultProvider', () => { aborted$: hot('|'), }; - const resultObs = provider.find('term', options); + const resultObs = provider.find({ term: 'term' }, options); expectObservable(resultObs).toBe('--(a|)', { a: [] }); }); @@ -209,7 +266,7 @@ describe('applicationResultProvider', () => { aborted$: hot('-(a|)', { a: undefined }), }; - const resultObs = provider.find('term', options); + const resultObs = provider.find({ term: 'term' }, options); expectObservable(resultObs).toBe('-|'); }); diff --git a/x-pack/plugins/global_search_providers/public/providers/application.ts b/x-pack/plugins/global_search_providers/public/providers/application.ts index 45264a3b2c52..fd6eb0dc1878 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { from } from 'rxjs'; +import { from, of } from 'rxjs'; import { take, map, takeUntil, mergeMap, shareReplay } from 'rxjs/operators'; import { ApplicationStart } from 'src/core/public'; import { GlobalSearchResultProvider } from '../../../global_search/public'; @@ -26,12 +26,15 @@ export const createApplicationResultProvider = ( return { id: 'application', - find: (term, { aborted$, maxResults }) => { + find: ({ term, types, tags }, { aborted$, maxResults }) => { + if (tags || (types && !types.includes('application'))) { + return of([]); + } return searchableApps$.pipe( takeUntil(aborted$), take(1), map((apps) => { - const results = getAppResults(term, [...apps.values()]); + const results = getAppResults(term ?? '', [...apps.values()]); return results.sort((a, b) => b.score - a.score).slice(0, maxResults); }) ); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts index 8798fe6694c9..ca5dbf802647 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.test.ts @@ -42,6 +42,7 @@ describe('mapToResult', () => { name: 'dashboard', management: { defaultSearchField: 'title', + icon: 'dashboardApp', getInAppUrl: (obj) => ({ path: `/dashboard/${obj.id}`, uiCapabilitiesPath: '' }), }, }); @@ -62,6 +63,7 @@ describe('mapToResult', () => { title: 'My dashboard', type: 'dashboard', url: '/dashboard/dash1', + icon: 'dashboardApp', score: 42, }); }); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts index 14641e1aafff..ec55a2a78fa9 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/map_object_to_result.ts @@ -50,6 +50,7 @@ export const mapToResult = ( // so we are forced to cast the attributes to any to access the properties associated with it. title: (object.attributes as any)[defaultSearchField], type: object.type, + icon: type.management?.icon ?? undefined, url: getInAppUrl(object).path, score: object.score, }; diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts index b556e2785b4b..da9276278dbb 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts @@ -116,7 +116,7 @@ describe('savedObjectsResultProvider', () => { }); it('calls `savedObjectClient.find` with the correct parameters', async () => { - await provider.find('term', defaultOption, context).toPromise(); + await provider.find({ term: 'term' }, defaultOption, context).toPromise(); expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ @@ -129,8 +129,56 @@ describe('savedObjectsResultProvider', () => { }); }); - it('does not call `savedObjectClient.find` if `term` is empty', async () => { - const results = await provider.find('', defaultOption, context).pipe(toArray()).toPromise(); + it('filters searchable types depending on the `types` parameter', async () => { + await provider.find({ term: 'term', types: ['typeA'] }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title'], + type: ['typeA'], + }); + }); + + it('ignore the case for the `types` parameter', async () => { + await provider.find({ term: 'term', types: ['TyPEa'] }, defaultOption, context).toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title'], + type: ['typeA'], + }); + }); + + it('calls `savedObjectClient.find` with the correct references when the `tags` option is set', async () => { + await provider + .find({ term: 'term', tags: ['tag-id-1', 'tag-id-2'] }, defaultOption, context) + .toPromise(); + + expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); + expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ + page: 1, + perPage: defaultOption.maxResults, + search: 'term*', + preference: 'pref', + searchFields: ['title', 'description'], + hasReference: [ + { type: 'tag', id: 'tag-id-1' }, + { type: 'tag', id: 'tag-id-2' }, + ], + type: ['typeA', 'typeB'], + }); + }); + + it('does not call `savedObjectClient.find` if all params are empty', async () => { + const results = await provider.find({}, defaultOption, context).pipe(toArray()).toPromise(); expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); expect(results).toEqual([[]]); @@ -144,7 +192,7 @@ describe('savedObjectsResultProvider', () => { ]) ); - const results = await provider.find('term', defaultOption, context).toPromise(); + const results = await provider.find({ term: 'term' }, defaultOption, context).toPromise(); expect(results).toEqual([ { id: 'resultA', @@ -172,7 +220,7 @@ describe('savedObjectsResultProvider', () => { ); const resultObs = provider.find( - 'term', + { term: 'term' }, { ...defaultOption, aborted$: hot('-(a|)', { a: undefined }) }, context ); diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts index 3861858a5362..3e2c42e7896f 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts @@ -6,14 +6,15 @@ import { from, combineLatest, of } from 'rxjs'; import { map, takeUntil, first } from 'rxjs/operators'; +import { SavedObjectsFindOptionsReference } from 'src/core/server'; import { GlobalSearchResultProvider } from '../../../../global_search/server'; import { mapToResults } from './map_object_to_result'; export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider => { return { id: 'savedObjects', - find: (term, { aborted$, maxResults, preference }, { core }) => { - if (!term) { + find: ({ term, types, tags }, { aborted$, maxResults, preference }, { core }) => { + if (!term && !types && !tags) { return of([]); } @@ -24,15 +25,22 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = const searchableTypes = typeRegistry .getVisibleTypes() + .filter(types ? (type) => includeIgnoreCase(types, type.name) : () => true) .filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl); + const searchFields = uniq( searchableTypes.map((type) => type.management!.defaultSearchField!) ); + const references: SavedObjectsFindOptionsReference[] | undefined = tags + ? tags.map((tagId) => ({ type: 'tag', id: tagId })) + : undefined; + const responsePromise = client.find({ page: 1, perPage: maxResults, search: term ? `${term}*` : undefined, + ...(references ? { hasReference: references } : {}), preference, searchFields, type: searchableTypes.map((type) => type.name), @@ -47,3 +55,6 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = }; const uniq = (values: T[]): T[] => [...new Set(values)]; + +const includeIgnoreCase = (list: string[], item: string) => + list.find((e) => e.toLowerCase() === item.toLowerCase()) !== undefined; diff --git a/x-pack/plugins/lens/public/search_provider.ts b/x-pack/plugins/lens/public/search_provider.ts index c19e7970b45a..02b7900a4c00 100644 --- a/x-pack/plugins/lens/public/search_provider.ts +++ b/x-pack/plugins/lens/public/search_provider.ts @@ -6,7 +6,7 @@ import levenshtein from 'js-levenshtein'; import { ApplicationStart } from 'kibana/public'; -import { from } from 'rxjs'; +import { from, of } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { GlobalSearchResultProvider } from '../../global_search/public'; @@ -26,7 +26,10 @@ export const getSearchProvider: ( uiCapabilities: Promise ) => GlobalSearchResultProvider = (uiCapabilities) => ({ id: 'lens', - find: (term) => { + find: ({ term = '', types, tags }) => { + if (tags || (types && !types.includes('application'))) { + return of([]); + } return from( uiCapabilities.then(({ navLinks: { visualize: visualizeNavLink } }) => { if (!visualizeNavLink) { diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts index 52ce8812454d..5d48404fca2b 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts @@ -8,7 +8,7 @@ import { OverlayStart } from 'src/core/public'; import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public'; import { TagsCapabilities } from '../../common'; import { ITagsCache, ITagInternalClient } from '../tags'; -import { getTagIdsFromReferences, updateTagsReferences } from '../utils'; +import { getTagIdsFromReferences, updateTagsReferences, convertTagNameToId } from '../utils'; import { getComponents } from './components'; import { buildGetTableColumnDefinition } from './get_table_column_definition'; import { buildGetSearchBarFilter } from './get_search_bar_filter'; @@ -39,6 +39,7 @@ export const getUiApi = ({ convertNameToReference: buildConvertNameToReference({ cache }), hasTagDecoration, getTagIdsFromReferences, + getTagIdFromName: (tagName: string) => convertTagNameToId(tagName, cache.getState()), updateTagsReferences, }; }; diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index da5b55f4aa2a..4c523ec5706e 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -37,6 +37,7 @@ import { RoleMappingsPageProvider } from './role_mappings_page'; import { SpaceSelectorPageProvider } from './space_selector_page'; import { IngestPipelinesPageProvider } from './ingest_pipelines_page'; import { TagManagementPageProvider } from './tag_management_page'; +import { NavigationalSearchProvider } from './navigational_search'; // just like services, PageObjects are defined as a map of // names to Providers. Merge in Kibana's or pick specific ones @@ -72,4 +73,5 @@ export const pageObjects = { lens: LensPageProvider, roleMappings: RoleMappingsPageProvider, ingestPipelines: IngestPipelinesPageProvider, + navigationalSearch: NavigationalSearchProvider, }; diff --git a/x-pack/test/functional/page_objects/navigational_search.ts b/x-pack/test/functional/page_objects/navigational_search.ts new file mode 100644 index 000000000000..77df829e3101 --- /dev/null +++ b/x-pack/test/functional/page_objects/navigational_search.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 { WebElementWrapper } from '../../../../test/functional/services/lib/web_element_wrapper'; +import { FtrProviderContext } from '../ftr_provider_context'; + +interface SearchResult { + label: string; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function NavigationalSearchProvider({ getService, getPageObjects }: FtrProviderContext) { + const find = getService('find'); + const testSubjects = getService('testSubjects'); + + class NavigationalSearch { + async focus() { + const field = await testSubjects.find('nav-search-input'); + await field.click(); + } + + async blur() { + await testSubjects.click('helpMenuButton'); + await testSubjects.click('helpMenuButton'); + await find.waitForDeletedByCssSelector('.navSearch__panel'); + } + + async searchFor( + term: string, + { clear = true, wait = true }: { clear?: boolean; wait?: boolean } = {} + ) { + if (clear) { + await this.clearField(); + } + const field = await testSubjects.find('nav-search-input'); + await field.type(term); + if (wait) { + await this.waitForResultsLoaded(); + } + } + + async clearField() { + const field = await testSubjects.find('nav-search-input'); + await field.clearValueWithKeyboard(); + } + + async isPopoverDisplayed() { + return await find.existsByCssSelector('.navSearch__panel'); + } + + async clickOnOption(index: number) { + const options = await testSubjects.findAll('nav-search-option'); + await options[index].click(); + } + + async waitForResultsLoaded(waitUntil: number = 3000) { + await testSubjects.exists('nav-search-option'); + // results are emitted in multiple batches. Each individual batch causes a re-render of + // the component, causing the current elements to become stale. We can't perform DOM access + // without heavy flakiness in this situation. + // there is NO ui indication of any kind to detect when all the emissions are done, + // so we are forced to fallback to awaiting a given amount of time once the first options are displayed. + await delay(waitUntil); + } + + async getDisplayedResults() { + const resultElements = await testSubjects.findAll('nav-search-option'); + return Promise.all(resultElements.map((el) => this.convertResultElement(el))); + } + + async isNoResultsPlaceholderDisplayed(checkAfter: number = 3000) { + // see comment in `waitForResultsLoaded` + await delay(checkAfter); + return testSubjects.exists('nav-search-no-results'); + } + + private async convertResultElement(resultEl: WebElementWrapper): Promise { + const labelEl = await find.allDescendantDisplayedByCssSelector( + '.euiSelectableTemplateSitewide__listItemTitle', + resultEl + ); + const label = await labelEl[0].getVisibleText(); + + return { + label, + }; + } + } + + return new NavigationalSearch(); +} diff --git a/x-pack/test/plugin_functional/config.ts b/x-pack/test/plugin_functional/config.ts index cb0b9f63906c..600c598fc6bd 100644 --- a/x-pack/test/plugin_functional/config.ts +++ b/x-pack/test/plugin_functional/config.ts @@ -5,6 +5,7 @@ */ import { resolve } from 'path'; import fs from 'fs'; +import { KIBANA_ROOT } from '@kbn/test'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { services } from './services'; import { pageObjects } from './page_objects'; @@ -39,6 +40,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xpackFunctionalConfig.get('kbnTestServer'), serverArgs: [ ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${resolve( + KIBANA_ROOT, + 'test/plugin_functional/plugins/core_provider_plugin' + )}`, ...plugins.map((pluginDir) => `--plugin-path=${resolve(__dirname, 'plugins', pluginDir)}`), ], }, diff --git a/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json new file mode 100644 index 000000000000..69220756639d --- /dev/null +++ b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/data.json @@ -0,0 +1,358 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-1", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-1", + "description": "My first tag!", + "color": "#FF00FF" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-2", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-2", + "description": "Another awesome tag", + "color": "#11FF22" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-3", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-3", + "description": "Last but not least", + "color": "#AA0077" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "tag:tag-4", + "index": ".kibana", + "source": { + "tag": { + "name": "tag-4", + "description": "Last", + "color": "#AA0077" + }, + "type": "tag", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:logstash-*", + "index": ".kibana", + "source": { + "index-pattern": { + "fieldFormatMap": "{\"bytes\":{\"id\":\"bytes\"}}", + "fields": "[{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":true,\"doc_values\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"indexed\":true,\"analyzed\":false,\"doc_values\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"indexed\":false,\"analyzed\":false,\"doc_values\":false}]", + "timeFieldName": "@timestamp", + "title": "logstash-*" + }, + "type": "index-pattern" + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-1", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 1 (tag-1)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-1", + "name": "tag-1-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-2", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 2 (tag-2)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-2", + "name": "tag-2-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-3", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 3 (tag-1 + tag-3)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-1", + "name": "tag-1-ref" + }, + { "type": "tag", + "id": "tag-3", + "name": "tag-3-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-4", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "Visualization 4 (tag-2)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-2", + "name": "tag-2-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "visualization:vis-area-5", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "title": "My awesome vis (tag-4)", + "description": "AreaChart", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"logstash-*\",\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"filter\":[]}" + }, + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"title\":\"Visualization AreaChart\",\"type\":\"area\"}" + }, + "references": [ + { "type": "tag", + "id": "tag-4", + "name": "tag-4-ref" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-2", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 1 (tag-2)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-2", + "name": "tag-2-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-3", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 2 (tag-3)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-3", + "name": "tag-3-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "dashboard:ref-to-tag-1-and-tag-3", + "index": ".kibana", + "source": { + "dashboard": { + "title": "dashboard 3 (tag-1 and tag-3)", + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[{\"meta\":{\"negate\":false,\"disabled\":false,\"alias\":null,\"type\":\"phrase\",\"key\":\"animal\",\"value\":\"dog\",\"params\":{\"query\":\"dog\",\"type\":\"phrase\"},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\"},\"query\":{\"match\":{\"animal\":{\"query\":\"dog\",\"type\":\"phrase\"}}},\"$state\":{\"store\":\"appState\"}}],\"highlightAll\":true,\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "panelsJSON": "[]", + "timeFrom": "Mon Apr 09 2018 17:56:08 GMT-0400", + "timeRestore": true, + "timeTo": "Wed Apr 11 2018 17:56:08 GMT-0400", + "version": 1 + }, + "migrationVersion": { + "dashboard": "7.3.0" + }, + "references": [ + { + "id": "tag-1", + "name": "tag-1-ref", + "type": "tag" + }, + { + "id": "tag-3", + "name": "tag-3-ref", + "type": "tag" + } + ], + "type": "dashboard", + "updated_at": "2018-04-11T21:57:52.253Z" + } + } +} diff --git a/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json new file mode 100644 index 000000000000..ec28b51de1d1 --- /dev/null +++ b/x-pack/test/plugin_functional/es_archives/global_search/search_syntax/mappings.json @@ -0,0 +1,266 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "migrationVersion": { + "dynamic": "true", + "properties": { + "dashboard": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "visualization": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json b/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json index 934c6cce6338..e081b47760b9 100644 --- a/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json +++ b/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json @@ -4,6 +4,6 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "global_search_test"], "requiredPlugins": ["globalSearch"], - "server": true, + "server": false, "ui": true } diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts b/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts index aba3512788f9..4e5adee4bce9 100644 --- a/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts +++ b/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { of } from 'rxjs'; import { map, reduce } from 'rxjs/operators'; import { Plugin, CoreSetup, CoreStart, AppMountParameters } from 'kibana/public'; import { @@ -12,13 +11,11 @@ import { GlobalSearchPluginStart, GlobalSearchResult, } from '../../../../../plugins/global_search/public'; -import { createResult } from '../common/utils'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface GlobalSearchTestPluginSetup {} export interface GlobalSearchTestPluginStart { - findTest: (term: string) => Promise; - findReal: (term: string) => Promise; + find: (term: string) => Promise; } export interface GlobalSearchTestPluginSetupDeps { @@ -48,25 +45,6 @@ export class GlobalSearchTestPlugin }, }); - globalSearch.registerResultProvider({ - id: 'gs_test_client', - find: (term, options) => { - if (term.includes('client')) { - return of([ - createResult({ - id: 'client1', - type: 'test_client_type', - }), - createResult({ - id: 'client2', - type: 'test_client_type', - }), - ]); - } - return of([]); - }, - }); - return {}; } @@ -75,23 +53,11 @@ export class GlobalSearchTestPlugin { globalSearch }: GlobalSearchTestPluginStartDeps ): GlobalSearchTestPluginStart { return { - findTest: (term) => - globalSearch - .find(term, {}) - .pipe( - map((batch) => batch.results), - // restrict to test type to avoid failure when real providers are present - map((results) => results.filter((r) => r.type.startsWith('test_'))), - reduce((memo, results) => [...memo, ...results]) - ) - .toPromise(), - findReal: (term) => + find: (term) => globalSearch - .find(term, {}) + .find({ term }, {}) .pipe( map((batch) => batch.results), - // remove test types - map((results) => results.filter((r) => !r.type.startsWith('test_'))), reduce((memo, results) => [...memo, ...results]) ) .toPromise(), diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts b/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts deleted file mode 100644 index 7f9cdf423718..000000000000 --- a/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { PluginInitializer } from 'src/core/server'; -import { - GlobalSearchTestPlugin, - GlobalSearchTestPluginSetup, - GlobalSearchTestPluginStart, - GlobalSearchTestPluginSetupDeps, - GlobalSearchTestPluginStartDeps, -} from './plugin'; - -export const plugin: PluginInitializer< - GlobalSearchTestPluginSetup, - GlobalSearchTestPluginStart, - GlobalSearchTestPluginSetupDeps, - GlobalSearchTestPluginStartDeps -> = () => new GlobalSearchTestPlugin(); diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts b/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts deleted file mode 100644 index d8ad94ab7420..000000000000 --- a/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { of } from 'rxjs'; -import { Plugin, CoreSetup, CoreStart } from 'kibana/server'; -import { - GlobalSearchPluginSetup, - GlobalSearchPluginStart, -} from '../../../../../plugins/global_search/server'; -import { createResult } from '../common/utils'; - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface GlobalSearchTestPluginSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface GlobalSearchTestPluginStart {} - -export interface GlobalSearchTestPluginSetupDeps { - globalSearch: GlobalSearchPluginSetup; -} -export interface GlobalSearchTestPluginStartDeps { - globalSearch: GlobalSearchPluginStart; -} - -export class GlobalSearchTestPlugin - implements - Plugin< - GlobalSearchTestPluginSetup, - GlobalSearchTestPluginStart, - GlobalSearchTestPluginSetupDeps, - GlobalSearchTestPluginStartDeps - > { - public setup(core: CoreSetup, { globalSearch }: GlobalSearchTestPluginSetupDeps) { - globalSearch.registerResultProvider({ - id: 'gs_test_server', - find: (term, options, context) => { - if (term.includes('server')) { - return of([ - createResult({ - id: 'server1', - type: 'test_server_type', - }), - createResult({ - id: 'server2', - type: 'test_server_type', - }), - ]); - } - return of([]); - }, - }); - - return {}; - } - - public start(core: CoreStart) { - return {}; - } -} diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts deleted file mode 100644 index 146c4297fc2c..000000000000 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; -import { GlobalSearchResult } from '../../../../plugins/global_search/common/types'; -import { GlobalSearchTestApi } from '../../plugins/global_search_test/public/types'; - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - const pageObjects = getPageObjects(['common']); - const browser = getService('browser'); - - const findResultsWithAPI = async (t: string): Promise => { - return browser.executeAsync(async (term, cb) => { - const { start } = window._coreProvider; - const globalSearchTestApi: GlobalSearchTestApi = start.plugins.globalSearchTest; - globalSearchTestApi.findTest(term).then(cb); - }, t); - }; - - describe('GlobalSearch API', function () { - beforeEach(async function () { - await pageObjects.common.navigateToApp('globalSearchTestApp'); - }); - - it('return no results when no provider return results', async () => { - const results = await findResultsWithAPI('no_match'); - expect(results.length).to.be(0); - }); - it('return results from the client provider', async () => { - const results = await findResultsWithAPI('client'); - expect(results.length).to.be(2); - expect(results.map((r) => r.id)).to.eql(['client1', 'client2']); - }); - it('return results from the server provider', async () => { - const results = await findResultsWithAPI('server'); - expect(results.length).to.be(2); - expect(results.map((r) => r.id)).to.eql(['server1', 'server2']); - }); - it('return mixed results from both client and server providers', async () => { - const results = await findResultsWithAPI('server+client'); - expect(results.length).to.be(4); - expect(results.map((r) => r.id)).to.eql(['client1', 'client2', 'server1', 'server2']); - }); - }); -} diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts index 005d516e2943..97d50bda899f 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/global_search_bar.ts @@ -8,33 +8,149 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { - // See: https://github.com/elastic/kibana/issues/81397 - describe.skip('GlobalSearchBar', function () { - const { common } = getPageObjects(['common']); - const find = getService('find'); - const testSubjects = getService('testSubjects'); + describe('GlobalSearchBar', function () { + const { common, navigationalSearch } = getPageObjects(['common', 'navigationalSearch']); + const esArchiver = getService('esArchiver'); const browser = getService('browser'); before(async () => { + await esArchiver.load('global_search/search_syntax'); await common.navigateToApp('home'); }); - it('basically works', async () => { - const field = await testSubjects.find('header-search'); - await field.click(); + after(async () => { + await esArchiver.unload('global_search/search_syntax'); + }); - expect((await testSubjects.findAll('header-search-option')).length).to.be(15); + afterEach(async () => { + await navigationalSearch.blur(); + }); - field.type('d'); + it('shows the popover on focus', async () => { + await navigationalSearch.focus(); - const options = await testSubjects.findAll('header-search-option'); + expect(await navigationalSearch.isPopoverDisplayed()).to.eql(true); - expect(options.length).to.be(6); + await navigationalSearch.blur(); - await options[1].click(); + expect(await navigationalSearch.isPopoverDisplayed()).to.eql(false); + }); + + it('redirects to the correct page', async () => { + await navigationalSearch.searchFor('type:application discover'); + await navigationalSearch.clickOnOption(0); expect(await browser.getCurrentUrl()).to.contain('discover'); - expect(await (await find.activeElement()).getTagName()).to.be('body'); + }); + + describe('advanced search syntax', () => { + it('allows to filter by type', async () => { + await navigationalSearch.searchFor('type:dashboard'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'dashboard 1 (tag-2)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by multiple types', async () => { + await navigationalSearch.searchFor('type:(dashboard OR visualization)'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 2 (tag-2)', + 'Visualization 3 (tag-1 + tag-3)', + 'Visualization 4 (tag-2)', + 'My awesome vis (tag-4)', + 'dashboard 1 (tag-2)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by tag', async () => { + await navigationalSearch.searchFor('tag:tag-1'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by multiple tags', async () => { + await navigationalSearch.searchFor('tag:tag-1 tag:tag-3'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by type and tag', async () => { + await navigationalSearch.searchFor('type:dashboard tag:tag-3'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by multiple types and tags', async () => { + await navigationalSearch.searchFor( + 'type:(dashboard OR visualization) tag:(tag-1 OR tag-3)' + ); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql([ + 'Visualization 1 (tag-1)', + 'Visualization 3 (tag-1 + tag-3)', + 'dashboard 2 (tag-3)', + 'dashboard 3 (tag-1 and tag-3)', + ]); + }); + + it('allows to filter by term and type', async () => { + await navigationalSearch.searchFor('type:visualization awesome'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql(['My awesome vis (tag-4)']); + }); + + it('allows to filter by term and tag', async () => { + await navigationalSearch.searchFor('tag:tag-4 awesome'); + + const results = await navigationalSearch.getDisplayedResults(); + + expect(results.map((result) => result.label)).to.eql(['My awesome vis (tag-4)']); + }); + + it('returns no results when searching for an unknown tag', async () => { + await navigationalSearch.searchFor('tag:unknown'); + + expect(await navigationalSearch.isNoResultsPlaceholderDisplayed()).to.eql(true); + }); + + it('returns no results when searching for an unknown type', async () => { + await navigationalSearch.searchFor('type:unknown'); + + expect(await navigationalSearch.isNoResultsPlaceholderDisplayed()).to.eql(true); + }); }); }); } diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts index 4b5b372c9264..16dc7b379214 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts @@ -18,7 +18,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { return browser.executeAsync(async (term, cb) => { const { start } = window._coreProvider; const globalSearchTestApi: GlobalSearchTestApi = start.plugins.globalSearchTest; - globalSearchTestApi.findReal(term).then(cb); + globalSearchTestApi.find(term).then(cb); }, t); }; diff --git a/x-pack/test/plugin_functional/test_suites/global_search/index.ts b/x-pack/test/plugin_functional/test_suites/global_search/index.ts index f43e293c30fd..f3557ee8cc8d 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/index.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/index.ts @@ -7,10 +7,8 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - // See https://github.com/elastic/kibana/issues/81397 - describe.skip('GlobalSearch API', function () { - this.tags('ciGroup7'); - loadTestFile(require.resolve('./global_search_api')); + describe('GlobalSearch API', function () { + this.tags('ciGroup10'); loadTestFile(require.resolve('./global_search_providers')); loadTestFile(require.resolve('./global_search_bar')); }); From 7156a575d42a404cf96133b6261e6023cc8ac760 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Tue, 24 Nov 2020 12:16:13 +0100 Subject: [PATCH 11/89] [Discover] Unskip and improve functional doc_table tests (#82430) --- test/functional/apps/discover/_doc_table.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/test/functional/apps/discover/_doc_table.ts b/test/functional/apps/discover/_doc_table.ts index dceb12a02f87..49b160cc7031 100644 --- a/test/functional/apps/discover/_doc_table.ts +++ b/test/functional/apps/discover/_doc_table.ts @@ -43,14 +43,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // and load a set of makelogs data await esArchiver.loadIfNeeded('logstash_functional'); await kibanaServer.uiSettings.replace(defaultSettings); + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); log.debug('discover doc table'); await PageObjects.common.navigateToApp('discover'); }); - beforeEach(async function () { - await PageObjects.timePicker.setDefaultAbsoluteRange(); - }); - it('should show the first 50 rows by default', async function () { // with the default range the number of hits is ~14000 const rows = await PageObjects.discover.getDocTableRows(); @@ -68,6 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const finalRows = await PageObjects.discover.getDocTableRows(); expect(finalRows.length).to.be.below(initialRows.length); + await PageObjects.timePicker.setDefaultAbsoluteRange(); }); it(`should load up to ${rowsHardLimit} rows when scrolling at the end of the table`, async function () { @@ -89,8 +87,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await footer.getVisibleText()).to.have.string(rowsHardLimit); }); - // FLAKY: https://github.com/elastic/kibana/issues/81632 - describe.skip('expand a document row', function () { + describe('expand a document row', function () { const rowToInspect = 1; beforeEach(async function () { // close the toggle if open From 21ed8a06f83e259e4c81659a90ab0454360a310c Mon Sep 17 00:00:00 2001 From: Jorge Sanz Date: Tue, 24 Nov 2020 12:20:47 +0100 Subject: [PATCH 12/89] Bump ems-client to 7.11 (#84136) --- package.json | 2 +- src/plugins/maps_legacy/common/ems_defaults.ts | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 8e94e5277b8e..d24a0c2700a0 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@babel/runtime": "^7.11.2", "@elastic/datemath": "link:packages/elastic-datemath", "@elastic/elasticsearch": "7.10.0-rc.1", - "@elastic/ems-client": "7.10.0", + "@elastic/ems-client": "7.11.0", "@elastic/eui": "30.2.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", diff --git a/src/plugins/maps_legacy/common/ems_defaults.ts b/src/plugins/maps_legacy/common/ems_defaults.ts index 583dca1dbf03..d1ae9e7983bc 100644 --- a/src/plugins/maps_legacy/common/ems_defaults.ts +++ b/src/plugins/maps_legacy/common/ems_defaults.ts @@ -20,6 +20,6 @@ // Default config for the elastic hosted EMS endpoints export const DEFAULT_EMS_FILE_API_URL = 'https://vector.maps.elastic.co'; export const DEFAULT_EMS_TILE_API_URL = 'https://tiles.maps.elastic.co'; -export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v7.10'; +export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v7.11'; export const DEFAULT_EMS_FONT_LIBRARY_URL = 'https://tiles.maps.elastic.co/fonts/{fontstack}/{range}.pbf'; diff --git a/yarn.lock b/yarn.lock index 355832d14e60..037606b60791 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1416,10 +1416,10 @@ pump "^3.0.0" secure-json-parse "^2.1.0" -"@elastic/ems-client@7.10.0": - version "7.10.0" - resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.10.0.tgz#6d0e12ce99acd122d8066aa0a8685ecfd21637d3" - integrity sha512-84XqAhY4iaKwo2PnDwskNLvnprR3EYcS1AhN048xa8mIZlRJuycB4DwWnB699qvUTQqKcg5qLS0o5sEUs2HDeA== +"@elastic/ems-client@7.11.0": + version "7.11.0" + resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.11.0.tgz#d2142d0ef5bd1aff7ae67b37c1394b73cdd48d8b" + integrity sha512-7+gDEkBr8nRS7X9i/UPg1WkS7bEBuNbBBjXCchQeYwqPRmw6vOb4wjlNzVwmOFsp2OH4lVFfZ+XU4pxTt32EXA== dependencies: lodash "^4.17.15" semver "7.3.2" From 423888c14ed2713a6cb3cb4943682cea1259c439 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 24 Nov 2020 12:37:27 +0100 Subject: [PATCH 13/89] [Lens] CSV Export for Lens (#83430) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...na-plugin-plugins-data-public.exporters.md | 14 +++ .../kibana-plugin-plugins-data-public.md | 1 + ...na-plugin-plugins-data-server.exporters.md | 14 +++ .../kibana-plugin-plugins-data-server.md | 1 + .../data/common/exports/export_csv.test.ts | 85 +++++++++++++++++++ .../data/common/exports/export_csv.tsx | 82 ++++++++++++++++++ src/plugins/data/common/exports/index.ts | 20 +++++ src/plugins/data/common/index.ts | 1 + src/plugins/data/public/index.ts | 10 +++ src/plugins/data/public/public.api.md | 55 +++++++----- src/plugins/data/server/index.ts | 10 +++ src/plugins/data/server/server.api.md | 81 ++++++++++-------- src/plugins/share/public/index.ts | 2 + src/plugins/share/public/lib/download_as.ts | 67 +++++++++++++++ x-pack/plugins/lens/kibana.json | 3 +- .../lens/public/app_plugin/app.test.tsx | 65 ++++++++++++++ x-pack/plugins/lens/public/app_plugin/app.tsx | 41 ++++++++- .../lens/public/app_plugin/lens_top_nav.tsx | 15 +++- .../plugins/lens/public/app_plugin/types.ts | 3 + .../editor_frame/editor_frame.tsx | 1 + .../editor_frame_service/editor_frame/save.ts | 3 + .../editor_frame/state_management.ts | 2 +- x-pack/plugins/lens/public/types.ts | 1 + .../test/functional/apps/lens/smokescreen.ts | 21 +++++ 24 files changed, 537 insertions(+), 61 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.exporters.md create mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.exporters.md create mode 100644 src/plugins/data/common/exports/export_csv.test.ts create mode 100644 src/plugins/data/common/exports/export_csv.tsx create mode 100644 src/plugins/data/common/exports/index.ts create mode 100644 src/plugins/share/public/lib/download_as.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.exporters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.exporters.md new file mode 100644 index 000000000000..883dbcfe289c --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.exporters.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [exporters](./kibana-plugin-plugins-data-public.exporters.md) + +## exporters variable + +Signature: + +```typescript +exporters: { + datatableToCSV: typeof datatableToCSV; + CSV_MIME_TYPE: string; +} +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index bafcd8bdffff..b8e45cde3c18 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -108,6 +108,7 @@ | [esFilters](./kibana-plugin-plugins-data-public.esfilters.md) | | | [esKuery](./kibana-plugin-plugins-data-public.eskuery.md) | | | [esQuery](./kibana-plugin-plugins-data-public.esquery.md) | | +| [exporters](./kibana-plugin-plugins-data-public.exporters.md) | | | [extractSearchSourceReferences](./kibana-plugin-plugins-data-public.extractsearchsourcereferences.md) | | | [fieldFormats](./kibana-plugin-plugins-data-public.fieldformats.md) | | | [fieldList](./kibana-plugin-plugins-data-public.fieldlist.md) | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.exporters.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.exporters.md new file mode 100644 index 000000000000..6fda400d09fd --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.exporters.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [exporters](./kibana-plugin-plugins-data-server.exporters.md) + +## exporters variable + +Signature: + +```typescript +exporters: { + datatableToCSV: typeof datatableToCSV; + CSV_MIME_TYPE: string; +} +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index 8957f6d0f06b..d9f14950be0e 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -76,6 +76,7 @@ | [esFilters](./kibana-plugin-plugins-data-server.esfilters.md) | | | [esKuery](./kibana-plugin-plugins-data-server.eskuery.md) | | | [esQuery](./kibana-plugin-plugins-data-server.esquery.md) | | +| [exporters](./kibana-plugin-plugins-data-server.exporters.md) | | | [fieldFormats](./kibana-plugin-plugins-data-server.fieldformats.md) | | | [indexPatterns](./kibana-plugin-plugins-data-server.indexpatterns.md) | | | [mergeCapabilitiesWithFields](./kibana-plugin-plugins-data-server.mergecapabilitieswithfields.md) | | diff --git a/src/plugins/data/common/exports/export_csv.test.ts b/src/plugins/data/common/exports/export_csv.test.ts new file mode 100644 index 000000000000..73878111b147 --- /dev/null +++ b/src/plugins/data/common/exports/export_csv.test.ts @@ -0,0 +1,85 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Datatable } from 'src/plugins/expressions'; +import { FieldFormat } from '../../common/field_formats'; +import { datatableToCSV } from './export_csv'; + +function getDefaultOptions() { + const formatFactory = jest.fn(); + formatFactory.mockReturnValue({ convert: (v: unknown) => `Formatted_${v}` } as FieldFormat); + return { + csvSeparator: ',', + quoteValues: true, + formatFactory, + }; +} + +function getDataTable({ multipleColumns }: { multipleColumns?: boolean } = {}): Datatable { + const layer1: Datatable = { + type: 'datatable', + columns: [{ id: 'col1', name: 'columnOne', meta: { type: 'string' } }], + rows: [{ col1: 'value' }], + }; + if (multipleColumns) { + layer1.columns.push({ id: 'col2', name: 'columnTwo', meta: { type: 'number' } }); + layer1.rows[0].col2 = 5; + } + return layer1; +} + +describe('CSV exporter', () => { + test('should not break with empty data', () => { + expect( + datatableToCSV({ type: 'datatable', columns: [], rows: [] }, getDefaultOptions()) + ).toMatch(''); + }); + + test('should export formatted values by default', () => { + expect(datatableToCSV(getDataTable(), getDefaultOptions())).toMatch( + 'columnOne\r\n"Formatted_value"\r\n' + ); + }); + + test('should not quote values when requested', () => { + return expect( + datatableToCSV(getDataTable(), { ...getDefaultOptions(), quoteValues: false }) + ).toMatch('columnOne\r\nFormatted_value\r\n'); + }); + + test('should use raw values when requested', () => { + expect(datatableToCSV(getDataTable(), { ...getDefaultOptions(), raw: true })).toMatch( + 'columnOne\r\nvalue\r\n' + ); + }); + + test('should use separator for multiple columns', () => { + expect(datatableToCSV(getDataTable({ multipleColumns: true }), getDefaultOptions())).toMatch( + 'columnOne,columnTwo\r\n"Formatted_value","Formatted_5"\r\n' + ); + }); + + test('should escape values', () => { + const datatable = getDataTable(); + datatable.rows[0].col1 = '"value"'; + expect(datatableToCSV(datatable, getDefaultOptions())).toMatch( + 'columnOne\r\n"Formatted_""value"""\r\n' + ); + }); +}); diff --git a/src/plugins/data/common/exports/export_csv.tsx b/src/plugins/data/common/exports/export_csv.tsx new file mode 100644 index 000000000000..1e1420c245eb --- /dev/null +++ b/src/plugins/data/common/exports/export_csv.tsx @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Inspired by the inspector CSV exporter + +import { FormatFactory } from 'src/plugins/data/common/field_formats/utils'; +import { Datatable } from 'src/plugins/expressions'; + +const LINE_FEED_CHARACTER = '\r\n'; +const nonAlphaNumRE = /[^a-zA-Z0-9]/; +const allDoubleQuoteRE = /"/g; +export const CSV_MIME_TYPE = 'text/plain;charset=utf-8'; + +// TODO: enhance this later on +function escape(val: object | string, quoteValues: boolean) { + if (val != null && typeof val === 'object') { + val = val.valueOf(); + } + + val = String(val); + + if (quoteValues && nonAlphaNumRE.test(val)) { + val = `"${val.replace(allDoubleQuoteRE, '""')}"`; + } + + return val; +} + +interface CSVOptions { + csvSeparator: string; + quoteValues: boolean; + formatFactory: FormatFactory; + raw?: boolean; +} + +export function datatableToCSV( + { columns, rows }: Datatable, + { csvSeparator, quoteValues, formatFactory, raw }: CSVOptions +) { + // Build the header row by its names + const header = columns.map((col) => escape(col.name, quoteValues)); + + const formatters = columns.reduce>>( + (memo, { id, meta }) => { + memo[id] = formatFactory(meta?.params); + return memo; + }, + {} + ); + + // Convert the array of row objects to an array of row arrays + const csvRows = rows.map((row) => { + return columns.map((column) => + escape(raw ? row[column.id] : formatters[column.id].convert(row[column.id]), quoteValues) + ); + }); + + if (header.length === 0) { + return ''; + } + + return ( + [header, ...csvRows].map((row) => row.join(csvSeparator)).join(LINE_FEED_CHARACTER) + + LINE_FEED_CHARACTER + ); // Add \r\n after last line +} diff --git a/src/plugins/data/common/exports/index.ts b/src/plugins/data/common/exports/index.ts new file mode 100644 index 000000000000..72faac654b42 --- /dev/null +++ b/src/plugins/data/common/exports/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { datatableToCSV, CSV_MIME_TYPE } from './export_csv'; diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index 2d6637daf432..36129a4d3f8c 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -26,6 +26,7 @@ export * from './query'; export * from './search'; export * from './types'; export * from './utils'; +export * from './exports'; /** * Use data plugin interface instead diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 129addf3de70..e0b0c5a0ea98 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -212,6 +212,16 @@ export { FieldFormat, } from '../common'; +/** + * Exporters (CSV) + */ + +import { datatableToCSV, CSV_MIME_TYPE } from '../common'; +export const exporters = { + datatableToCSV, + CSV_MIME_TYPE, +}; + /* * Index patterns: */ diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 5a707393b39f..e1af3cc1d1b4 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -17,7 +17,8 @@ import { CoreSetup } from 'src/core/public'; import { CoreSetup as CoreSetup_2 } from 'kibana/public'; import { CoreStart } from 'kibana/public'; import { CoreStart as CoreStart_2 } from 'src/core/public'; -import { Datatable as Datatable_2 } from 'src/plugins/expressions/common'; +import { Datatable as Datatable_2 } from 'src/plugins/expressions'; +import { Datatable as Datatable_3 } from 'src/plugins/expressions/common'; import { DatatableColumn as DatatableColumn_2 } from 'src/plugins/expressions'; import { Ensure } from '@kbn/utility-types'; import { EnvironmentMode } from '@kbn/config'; @@ -35,6 +36,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { ExpressionFunctionDefinition as ExpressionFunctionDefinition_2 } from 'src/plugins/expressions/public'; import { ExpressionsSetup } from 'src/plugins/expressions/public'; import { ExpressionValueBoxed } from 'src/plugins/expressions/common'; +import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_formats/utils'; import { History } from 'history'; import { Href } from 'history'; import { IconType } from '@elastic/eui'; @@ -672,6 +674,14 @@ export type ExistsFilter = Filter & { exists?: FilterExistsProperty; }; +// Warning: (ae-missing-release-tag) "exporters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const exporters: { + datatableToCSV: typeof datatableToCSV; + CSV_MIME_TYPE: string; +}; + // Warning: (ae-missing-release-tag) "ExpressionFunctionKibana" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2392,27 +2402,28 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:413:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:220:23 - (ae-forgotten-export) The symbol "datatableToCSV" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:246:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:415:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:423:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:426:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:430:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:45:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index e24869f5237e..9d85caa624e7 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -49,6 +49,16 @@ export const esFilters = { isFilterDisabled, }; +/** + * Exporters (CSV) + */ + +import { datatableToCSV, CSV_MIME_TYPE } from '../common'; +export const exporters = { + datatableToCSV, + CSV_MIME_TYPE, +}; + /* * esQuery and esKuery: */ diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 94114288eb1f..6583651e074c 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -14,7 +14,8 @@ import { CoreSetup } from 'src/core/server'; import { CoreSetup as CoreSetup_2 } from 'kibana/server'; import { CoreStart } from 'src/core/server'; import { CoreStart as CoreStart_2 } from 'kibana/server'; -import { Datatable } from 'src/plugins/expressions/common'; +import { Datatable } from 'src/plugins/expressions'; +import { Datatable as Datatable_2 } from 'src/plugins/expressions/common'; import { DatatableColumn } from 'src/plugins/expressions'; import { Duration } from 'moment'; import { ElasticsearchClient } from 'src/core/server'; @@ -27,6 +28,7 @@ import { ExpressionAstFunction } from 'src/plugins/expressions/common'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { ExpressionValueBoxed } from 'src/plugins/expressions/common'; +import { FormatFactory } from 'src/plugins/data/common/field_formats/utils'; import { ISavedObjectsRepository } from 'src/core/server'; import { IScopedClusterClient } from 'src/core/server'; import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public'; @@ -299,6 +301,14 @@ export type ExecutionContextSearch = { timeRange?: TimeRange; }; +// Warning: (ae-missing-release-tag) "exporters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const exporters: { + datatableToCSV: typeof datatableToCSV; + CSV_MIME_TYPE: string; +}; + // Warning: (ae-missing-release-tag) "ExpressionFunctionKibana" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1216,40 +1226,41 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:135:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildCustomFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:243:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:258:5 - (ae-forgotten-export) The symbol "getTotalLoaded" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:259:5 - (ae-forgotten-export) The symbol "toSnakeCase" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:263:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:264:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:273:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:274:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:275:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:279:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:280:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:284:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:287:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:57:23 - (ae-forgotten-export) The symbol "datatableToCSV" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:81:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:81:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:137:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:137:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:253:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:253:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:253:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:253:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:268:5 - (ae-forgotten-export) The symbol "getTotalLoaded" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:269:5 - (ae-forgotten-export) The symbol "toSnakeCase" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:273:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:274:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:283:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:284:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:285:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:289:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:290:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:294:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:297:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index_patterns/index_patterns_service.ts:58:14 - (ae-forgotten-export) The symbol "IndexPatternsService" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:88:66 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts // src/plugins/data/server/search/types.ts:104:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index 950ecebeaadc..9f98d9c21d23 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -41,5 +41,7 @@ export { import { SharePlugin } from './plugin'; export { KibanaURL } from './kibana_url'; +export { downloadMultipleAs, downloadFileAs } from './lib/download_as'; +export type { DownloadableContent } from './lib/download_as'; export const plugin = () => new SharePlugin(); diff --git a/src/plugins/share/public/lib/download_as.ts b/src/plugins/share/public/lib/download_as.ts new file mode 100644 index 000000000000..6f40b894f85b --- /dev/null +++ b/src/plugins/share/public/lib/download_as.ts @@ -0,0 +1,67 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// @ts-ignore +import { saveAs } from '@elastic/filesaver'; +import pMap from 'p-map'; + +export type DownloadableContent = { content: string; type: string } | Blob; + +/** + * Convenient method to use for a single file download + * **Note**: for multiple files use the downloadMultipleAs method, do not iterate with this method here + * @param filename full name of the file + * @param payload either a Blob content, or a Record with a stringified content and type + * + * @returns a Promise that resolves when the download has been correctly started + */ +export function downloadFileAs(filename: string, payload: DownloadableContent) { + return downloadMultipleAs({ [filename]: payload }); +} + +/** + * Multiple files download method + * @param files a Record containing one entry per file: the key entry should be the filename + * and the value either a Blob content, or a Record with a stringified content and type + * + * @returns a Promise that resolves when all the downloads have been correctly started + */ +export async function downloadMultipleAs(files: Record) { + const filenames = Object.keys(files); + const downloadQueue = filenames.map((filename, i) => { + const payload = files[filename]; + const blob = + // probably this is enough? It does not support Node or custom implementations + payload instanceof Blob ? payload : new Blob([payload.content], { type: payload.type }); + + // TODO: remove this workaround for multiple files when fixed (in filesaver?) + return () => Promise.resolve().then(() => saveAs(blob, filename)); + }); + + // There's a bug in some browser with multiple files downloaded at once + // * sometimes only the first/last content is downloaded multiple times + // * sometimes only the first/last filename is used multiple times + await pMap(downloadQueue, (downloadFn) => Promise.all([downloadFn(), wait(50)]), { + concurrency: 1, + }); +} +// Probably there's already another one around? +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index ce78757676bc..5476be50fee8 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -14,7 +14,8 @@ "dashboard", "charts", "uiActions", - "embeddable" + "embeddable", + "share" ], "optionalPlugins": ["usageCollection", "taskManager", "globalSearch", "savedObjectsTagging"], "configPath": ["xpack", "lens"], diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index a211416472f4..7cd33bd25855 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -895,6 +895,71 @@ describe('Lens App', () => { }); }); + describe('download button', () => { + function getButton(inst: ReactWrapper): TopNavMenuData { + return (inst + .find('[data-test-subj="lnsApp_topNav"]') + .prop('config') as TopNavMenuData[]).find( + (button) => button.testId === 'lnsApp_downloadCSVButton' + )!; + } + + it('should be disabled when no data is available', async () => { + const { component, frame } = mountWith({}); + const onChange = frame.mount.mock.calls[0][1].onChange; + await act(async () => + onChange({ + filterableIndexPatterns: [], + doc: ({} as unknown) as Document, + isSaveable: true, + }) + ); + component.update(); + expect(getButton(component).disableButton).toEqual(true); + }); + + it('should disable download when not saveable', async () => { + const { component, frame } = mountWith({}); + const onChange = frame.mount.mock.calls[0][1].onChange; + + await act(async () => + onChange({ + filterableIndexPatterns: [], + doc: ({} as unknown) as Document, + isSaveable: false, + activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, + }) + ); + + component.update(); + expect(getButton(component).disableButton).toEqual(true); + }); + + it('should still be enabled even if the user is missing save permissions', async () => { + const services = makeDefaultServices(); + services.application = { + ...services.application, + capabilities: { + ...services.application.capabilities, + visualize: { save: false, saveQuery: false, show: true }, + }, + }; + + const { component, frame } = mountWith({ services }); + const onChange = frame.mount.mock.calls[0][1].onChange; + await act(async () => + onChange({ + filterableIndexPatterns: [], + doc: ({} as unknown) as Document, + isSaveable: true, + activeData: { layer1: { type: 'datatable', columns: [], rows: [] } }, + }) + ); + component.update(); + expect(getButton(component).disableButton).toEqual(false); + }); + }); + describe('query bar state management', () => { it('uses the default time and query language settings', () => { const { frame } = mountWith({}); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index cdd701271be2..addc263acca2 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -11,6 +11,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'kibana/public'; import { EuiBreadcrumb } from '@elastic/eui'; +import { downloadMultipleAs } from '../../../../../src/plugins/share/public'; import { createKbnUrlStateStorage, withNotifyOnErrors, @@ -25,6 +26,7 @@ import { NativeRenderer } from '../native_renderer'; import { trackUiEvent } from '../lens_ui_telemetry'; import { esFilters, + exporters, IndexPattern as IndexPatternInstance, IndexPatternsContract, syncQueryStateWithUrl, @@ -474,16 +476,50 @@ export function App({ const { TopNavMenu } = navigation.ui; const savingPermitted = Boolean(state.isSaveable && application.capabilities.visualize.save); + const unsavedTitle = i18n.translate('xpack.lens.app.unsavedFilename', { + defaultMessage: 'unsaved', + }); const topNavConfig = getLensTopNavConfig({ showSaveAndReturn: Boolean( state.isLinkedToOriginatingApp && // Temporarily required until the 'by value' paradigm is default. (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput)) ), + enableExportToCSV: Boolean( + state.isSaveable && state.activeData && Object.keys(state.activeData).length + ), isByValueMode: getIsByValueMode(), showCancel: Boolean(state.isLinkedToOriginatingApp), savingPermitted, actions: { + exportToCSV: () => { + if (!state.activeData) { + return; + } + const datatables = Object.values(state.activeData); + const content = datatables.reduce>( + (memo, datatable, i) => { + // skip empty datatables + if (datatable) { + const postFix = datatables.length > 1 ? `-${i + 1}` : ''; + + memo[`${lastKnownDoc?.title || unsavedTitle}${postFix}.csv`] = { + content: exporters.datatableToCSV(datatable, { + csvSeparator: uiSettings.get('csv:separator', ','), + quoteValues: uiSettings.get('csv:quoteValues', true), + formatFactory: data.fieldFormats.deserialize, + }), + type: exporters.CSV_MIME_TYPE, + }; + } + return memo; + }, + {} + ); + if (content) { + downloadMultipleAs(content); + } + }, saveAndReturn: () => { if (savingPermitted && lastKnownDoc) { // disabling the validation on app leave because the document has been saved. @@ -605,13 +641,16 @@ export function App({ onError, showNoDataPopover, initialContext, - onChange: ({ filterableIndexPatterns, doc, isSaveable }) => { + onChange: ({ filterableIndexPatterns, doc, isSaveable, activeData }) => { if (isSaveable !== state.isSaveable) { setState((s) => ({ ...s, isSaveable })); } if (!_.isEqual(state.persistedDoc, doc)) { setState((s) => ({ ...s, lastKnownDoc: doc })); } + if (!_.isEqual(state.activeData, activeData)) { + setState((s) => ({ ...s, activeData })); + } // Update the cached index patterns if the user made a change to any of them if ( diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index 9162af52052e..2c23dc291405 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -10,12 +10,13 @@ import { LensTopNavActions } from './types'; export function getLensTopNavConfig(options: { showSaveAndReturn: boolean; + enableExportToCSV: boolean; showCancel: boolean; isByValueMode: boolean; actions: LensTopNavActions; savingPermitted: boolean; }): TopNavMenuData[] { - const { showSaveAndReturn, showCancel, actions, savingPermitted } = options; + const { showSaveAndReturn, showCancel, actions, savingPermitted, enableExportToCSV } = options; const topNavMenu: TopNavMenuData[] = []; const saveButtonLabel = options.isByValueMode @@ -30,6 +31,18 @@ export function getLensTopNavConfig(options: { defaultMessage: 'Save', }); + topNavMenu.push({ + label: i18n.translate('xpack.lens.app.downloadCSV', { + defaultMessage: 'Download as CSV', + }), + run: actions.exportToCSV, + testId: 'lnsApp_downloadCSVButton', + description: i18n.translate('xpack.lens.app.downloadButtonAriaLabel', { + defaultMessage: 'Download the data as CSV file', + }), + disableButton: !enableExportToCSV, + }); + if (showCancel) { topNavMenu.push({ label: i18n.translate('xpack.lens.app.cancel', { diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index 6c222bed7a83..07dc69078e33 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -34,6 +34,7 @@ import { ACTION_VISUALIZE_LENS_FIELD, } from '../../../../../src/plugins/ui_actions/public'; import { EmbeddableEditorState } from '../../../../../src/plugins/embeddable/public'; +import { TableInspectorAdapter } from '../editor_frame_service/types'; import { EditorFrameInstance } from '..'; export interface LensAppState { @@ -60,6 +61,7 @@ export interface LensAppState { filters: Filter[]; savedQuery?: SavedQuery; isSaveable: boolean; + activeData?: TableInspectorAdapter; } export interface RedirectToOriginProps { @@ -111,4 +113,5 @@ export interface LensTopNavActions { saveAndReturn: () => void; showSaveModal: () => void; cancel: () => void; + exportToCSV: () => void; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 935d65bfb6c0..fea9723aa700 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -244,6 +244,7 @@ export function EditorFrame(props: EditorFrameProps) { activeVisualization, state.datasourceStates, state.visualization, + state.activeData, props.query, props.dateRange, props.filters, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts index 4cb523f128a8..eec3f68ced5f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.ts @@ -6,6 +6,7 @@ import _ from 'lodash'; import { SavedObjectReference } from 'kibana/public'; +import { Datatable } from 'src/plugins/expressions'; import { EditorFrameState } from './state_management'; import { Document } from '../../persistence/saved_object_store'; import { Datasource, Visualization, FramePublicAPI } from '../../types'; @@ -28,6 +29,7 @@ export function getSavedObjectFormat({ doc: Document; filterableIndexPatterns: string[]; isSaveable: boolean; + activeData: Record | undefined; } { const datasourceStates: Record = {}; const references: SavedObjectReference[] = []; @@ -74,5 +76,6 @@ export function getSavedObjectFormat({ }, filterableIndexPatterns: uniqueFilterableIndexPatternIds, isSaveable: expression !== null, + activeData: state.activeData, }; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts index e0101493b27a..55a4cb567fda 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.ts @@ -148,7 +148,7 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta case 'UPDATE_ACTIVE_DATA': return { ...state, - activeData: action.tables, + activeData: { ...action.tables }, }; case 'UPDATE_LAYER': return { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 225fedb987c7..2f40f2145531 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -50,6 +50,7 @@ export interface EditorFrameProps { filterableIndexPatterns: string[]; doc: Document; isSaveable: boolean; + activeData?: Record; }) => void; showNoDataPopover: () => void; } diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 29b42230673c..b91399a4a675 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -330,5 +330,26 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.lens.switchFirstLayerIndexPattern('log*'); expect(await PageObjects.lens.getFirstLayerIndexPattern()).to.equal('log*'); }); + + it('should show a download button only when the configuration is valid', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('pie'); + await PageObjects.lens.configureDimension({ + dimension: 'lnsPie_sliceByDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + // incomplete configuration should not be downloadable + expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(false); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsPie_sizeByDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(true); + }); }); } From 7d8ca10fbc8c736381c6952672e47656ae6e1e22 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 24 Nov 2020 13:26:54 +0100 Subject: [PATCH 14/89] [Lens] Do not reset filter state on incoming app navigation (#83786) --- .../services/dashboard/add_panel.ts | 5 ++++ .../lens/public/app_plugin/app.test.tsx | 4 +++ x-pack/plugins/lens/public/app_plugin/app.tsx | 6 ++++- x-pack/test/functional/apps/lens/dashboard.ts | 25 ++++++++++++++++++- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/test/functional/services/dashboard/add_panel.ts b/test/functional/services/dashboard/add_panel.ts index 1263501aa9c1..814a91148669 100644 --- a/test/functional/services/dashboard/add_panel.ts +++ b/test/functional/services/dashboard/add_panel.ts @@ -41,6 +41,11 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }: FtrPro await PageObjects.common.sleep(500); } + async clickVisType(visType: string) { + log.debug('DashboardAddPanel.clickVisType'); + await testSubjects.click(`visType-${visType}`); + } + async clickAddNewEmbeddableLink(type: string) { await testSubjects.click('createNew'); await testSubjects.click(`createNew-${type}`); diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 7cd33bd25855..6eef961a52e9 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -308,6 +308,9 @@ describe('Lens App', () => { const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; const pinnedFilter = esFilters.buildExistsFilter(pinnedField, indexPattern); services.data.query.filterManager.getFilters = jest.fn().mockImplementation(() => { + return []; + }); + services.data.query.filterManager.getGlobalFilters = jest.fn().mockImplementation(() => { return [pinnedFilter]; }); const { component, frame } = mountWith({ services }); @@ -322,6 +325,7 @@ describe('Lens App', () => { filters: [pinnedFilter], }) ); + expect(services.data.query.filterManager.getFilters).not.toHaveBeenCalled(); }); it('displays errors from the frame in a toast', () => { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index addc263acca2..3066f85bbf3f 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -72,7 +72,11 @@ export function App({ const currentRange = data.query.timefilter.timefilter.getTime(); return { query: data.query.queryString.getQuery(), - filters: data.query.filterManager.getFilters(), + // Do not use app-specific filters from previous app, + // only if Lens was opened with the intention to visualize a field (e.g. coming from Discover) + filters: !initialContext + ? data.query.filterManager.getGlobalFilters() + : data.query.filterManager.getFilters(), isLoading: Boolean(initialInput), indexPatternsForTopNav: [], dateRange: { diff --git a/x-pack/test/functional/apps/lens/dashboard.ts b/x-pack/test/functional/apps/lens/dashboard.ts index c24f4ccf01bc..17b70b8510f0 100644 --- a/x-pack/test/functional/apps/lens/dashboard.ts +++ b/x-pack/test/functional/apps/lens/dashboard.ts @@ -36,7 +36,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await PageObjects.common.navigateToApp('dashboard'); await security.testUser.setRoles( - ['global_dashboard_all', 'global_discover_all', 'test_logstash_reader'], + [ + 'global_dashboard_all', + 'global_discover_all', + 'test_logstash_reader', + 'global_visualize_all', + ], false ); }); @@ -116,6 +121,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); const hasGeoDestFilter = await filterBar.hasFilter('geo.dest', 'LS'); expect(hasGeoDestFilter).to.be(true); + await filterBar.addFilter('geo.src', 'is', 'US'); + await filterBar.toggleFilterPinned('geo.src'); + }); + + it('should not carry over filters if creating a new lens visualization from within dashboard', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await filterBar.addFilter('geo.src', 'is', 'US'); + await filterBar.toggleFilterPinned('geo.src'); + await filterBar.addFilter('geo.dest', 'is', 'LS'); + + await dashboardAddPanel.clickCreateNewLink(); + await dashboardAddPanel.clickVisType('lens'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const hasGeoDestFilter = await filterBar.hasFilter('geo.dest', 'LS'); + expect(hasGeoDestFilter).to.be(false); + const hasGeoSrcFilter = await filterBar.hasFilter('geo.src', 'US', true, true); + expect(hasGeoSrcFilter).to.be(true); }); }); } From 37146587607c503c7dff11b44c5e819aebed1156 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 24 Nov 2020 06:37:24 -0600 Subject: [PATCH 15/89] Transaction type handling and breakdown chart (#83587) --- x-pack/plugins/apm/common/agent_name.test.ts | 82 +++++++++++++++++++ x-pack/plugins/apm/common/agent_name.ts | 30 ++++++- x-pack/plugins/apm/jest.config.js | 7 +- .../service_details/service_detail_tabs.tsx | 2 +- .../components/app/service_overview/index.tsx | 18 +--- .../service_overview.test.tsx | 12 +++ .../TransactionList.stories.tsx | 0 .../TransactionList/index.tsx | 0 .../index.tsx | 27 ++---- .../transaction_overview.test.tsx} | 0 .../useRedirect.ts | 0 .../user_experience_callout.tsx | 0 .../shared/charts/spark_plot/index.tsx | 12 +-- .../shared/charts/timeseries_chart.tsx | 10 +-- .../transaction_breakdown_chart}/index.tsx | 22 +++-- .../transaction_breakdown_chart_contents.tsx} | 22 +++-- .../charts/transaction_charts/index.tsx | 4 +- ...akdown.ts => use_transaction_breakdown.ts} | 8 +- .../apm/public/hooks/use_transaction_type.ts | 28 +++++++ .../public/hooks/use_chart_theme.tsx | 16 +++- 20 files changed, 213 insertions(+), 87 deletions(-) create mode 100644 x-pack/plugins/apm/common/agent_name.test.ts rename x-pack/plugins/apm/public/components/app/{TransactionOverview => transaction_overview}/TransactionList/TransactionList.stories.tsx (100%) rename x-pack/plugins/apm/public/components/app/{TransactionOverview => transaction_overview}/TransactionList/index.tsx (100%) rename x-pack/plugins/apm/public/components/app/{TransactionOverview => transaction_overview}/index.tsx (91%) rename x-pack/plugins/apm/public/components/app/{TransactionOverview/TransactionOverview.test.tsx => transaction_overview/transaction_overview.test.tsx} (100%) rename x-pack/plugins/apm/public/components/app/{TransactionOverview => transaction_overview}/useRedirect.ts (100%) rename x-pack/plugins/apm/public/components/app/{TransactionOverview => transaction_overview}/user_experience_callout.tsx (100%) rename x-pack/plugins/apm/public/components/shared/{TransactionBreakdown => charts/transaction_breakdown_chart}/index.tsx (66%) rename x-pack/plugins/apm/public/components/shared/{TransactionBreakdown/TransactionBreakdownGraph/index.tsx => charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx} (88%) rename x-pack/plugins/apm/public/hooks/{useTransactionBreakdown.ts => use_transaction_breakdown.ts} (84%) create mode 100644 x-pack/plugins/apm/public/hooks/use_transaction_type.ts diff --git a/x-pack/plugins/apm/common/agent_name.test.ts b/x-pack/plugins/apm/common/agent_name.test.ts new file mode 100644 index 000000000000..f4ac2aa220e8 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_name.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { + getFirstTransactionType, + isJavaAgentName, + isRumAgentName, +} from './agent_name'; + +describe('agent name helpers', () => { + describe('getFirstTransactionType', () => { + describe('with no transaction types', () => { + expect(getFirstTransactionType([])).toBeUndefined(); + }); + + describe('with a non-rum agent', () => { + it('returns "request"', () => { + expect(getFirstTransactionType(['worker', 'request'], 'java')).toEqual( + 'request' + ); + }); + + describe('with no request types', () => { + it('returns the first type', () => { + expect( + getFirstTransactionType(['worker', 'shirker'], 'java') + ).toEqual('worker'); + }); + }); + }); + + describe('with a rum agent', () => { + it('returns "page-load"', () => { + expect( + getFirstTransactionType(['http-request', 'page-load'], 'js-base') + ).toEqual('page-load'); + }); + }); + }); + + describe('isJavaAgentName', () => { + describe('when the agent name is java', () => { + it('returns true', () => { + expect(isJavaAgentName('java')).toEqual(true); + }); + }); + describe('when the agent name is not java', () => { + it('returns true', () => { + expect(isJavaAgentName('not java')).toEqual(false); + }); + }); + }); + + describe('isRumAgentName', () => { + describe('when the agent name is js-base', () => { + it('returns true', () => { + expect(isRumAgentName('js-base')).toEqual(true); + }); + }); + + describe('when the agent name is rum-js', () => { + it('returns true', () => { + expect(isRumAgentName('rum-js')).toEqual(true); + }); + }); + + describe('when the agent name is opentelemetry/webjs', () => { + it('returns true', () => { + expect(isRumAgentName('opentelemetry/webjs')).toEqual(true); + }); + }); + + describe('when the agent name something else', () => { + it('returns true', () => { + expect(isRumAgentName('java')).toEqual(false); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index ca9e59e050c9..916fe65684a6 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -5,6 +5,10 @@ */ import { AgentName } from '../typings/es_schemas/ui/fields/agent'; +import { + TRANSACTION_PAGE_LOAD, + TRANSACTION_REQUEST, +} from './transaction_types'; /* * Agent names can be any string. This list only defines the official agents @@ -46,10 +50,24 @@ export const RUM_AGENT_NAMES: AgentName[] = [ 'opentelemetry/webjs', ]; -export function isRumAgentName( +function getDefaultTransactionTypeForAgentName(agentName?: string) { + return isRumAgentName(agentName) + ? TRANSACTION_PAGE_LOAD + : TRANSACTION_REQUEST; +} + +export function getFirstTransactionType( + transactionTypes: string[], agentName?: string -): agentName is 'js-base' | 'rum-js' | 'opentelemetry/webjs' { - return RUM_AGENT_NAMES.includes(agentName! as AgentName); +) { + const defaultTransactionType = getDefaultTransactionTypeForAgentName( + agentName + ); + + return ( + transactionTypes.find((type) => type === defaultTransactionType) ?? + transactionTypes[0] + ); } export function isJavaAgentName( @@ -57,3 +75,9 @@ export function isJavaAgentName( ): agentName is 'java' { return agentName === 'java'; } + +export function isRumAgentName( + agentName?: string +): agentName is 'js-base' | 'rum-js' | 'opentelemetry/webjs' { + return RUM_AGENT_NAMES.includes(agentName! as AgentName); +} diff --git a/x-pack/plugins/apm/jest.config.js b/x-pack/plugins/apm/jest.config.js index 849dd7f5c3e2..2a5ef9ad0c2a 100644 --- a/x-pack/plugins/apm/jest.config.js +++ b/x-pack/plugins/apm/jest.config.js @@ -14,14 +14,9 @@ const { createJestConfig } = require('../../dev-tools/jest/create_jest_config'); const { resolve } = require('path'); const rootDir = resolve(__dirname, '.'); -const xPackKibanaDirectory = resolve(__dirname, '../..'); const kibanaDirectory = resolve(__dirname, '../../..'); -const jestConfig = createJestConfig({ - kibanaDirectory, - rootDir, - xPackKibanaDirectory, -}); +const jestConfig = createJestConfig({ kibanaDirectory, rootDir }); module.exports = { ...jestConfig, diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx index 22c5a2b101dd..92eb3753e798 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx @@ -23,7 +23,7 @@ import { ServiceMap } from '../ServiceMap'; import { ServiceMetrics } from '../service_metrics'; import { ServiceNodeOverview } from '../ServiceNodeOverview'; import { ServiceOverview } from '../service_overview'; -import { TransactionOverview } from '../TransactionOverview'; +import { TransactionOverview } from '../transaction_overview'; interface Tab { key: string; diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index f734abe27573..33027f3946d1 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -16,6 +16,7 @@ import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink'; import { SearchBar } from '../../shared/search_bar'; @@ -103,22 +104,7 @@ export function ServiceOverview({ - - - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.averageDurationBySpanTypeChartTitle', - { - defaultMessage: 'Average duration by span type', - } - )} -

-
-
-
-
+
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index 8f9e76a5a79a..e4ef7428ba8d 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -17,6 +17,8 @@ import { MockUrlParamsContextProvider } from '../../../context/UrlParamsContext/ import * as useDynamicIndexPatternHooks from '../../../hooks/useDynamicIndexPattern'; import * as useFetcherHooks from '../../../hooks/useFetcher'; import { FETCH_STATUS } from '../../../hooks/useFetcher'; +import * as useAnnotationsHooks from '../../../hooks/use_annotations'; +import * as useTransactionBreakdownHooks from '../../../hooks/use_transaction_breakdown'; import { renderWithTheme } from '../../../utils/testHelpers'; import { ServiceOverview } from './'; @@ -53,6 +55,9 @@ function Wrapper({ children }: { children?: ReactNode }) { describe('ServiceOverview', () => { it('renders', () => { + jest + .spyOn(useAnnotationsHooks, 'useAnnotations') + .mockReturnValue({ annotations: [] }); jest .spyOn(useDynamicIndexPatternHooks, 'useDynamicIndexPattern') .mockReturnValue({ @@ -71,6 +76,13 @@ describe('ServiceOverview', () => { refetch: () => {}, status: FETCH_STATUS.SUCCESS, }); + jest + .spyOn(useTransactionBreakdownHooks, 'useTransactionBreakdown') + .mockReturnValue({ + data: { timeseries: [] }, + error: undefined, + status: FETCH_STATUS.SUCCESS, + }); expect(() => renderWithTheme(, { diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx rename to x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/TransactionList.stories.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx similarity index 91% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx rename to x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index a55b135c6a84..45a6114c88af 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -18,7 +18,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { Location } from 'history'; -import { first } from 'lodash'; import React, { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useTrackPageview } from '../../../../../observability/public'; @@ -29,6 +28,7 @@ import { useServiceTransactionTypes } from '../../../hooks/useServiceTransaction import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { useTransactionList } from '../../../hooks/useTransactionList'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useTransactionType } from '../../../hooks/use_transaction_type'; import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; @@ -41,23 +41,22 @@ import { useRedirect } from './useRedirect'; import { UserExperienceCallout } from './user_experience_callout'; function getRedirectLocation({ - urlParams, location, - serviceTransactionTypes, + transactionType, + urlParams, }: { location: Location; + transactionType?: string; urlParams: IUrlParams; - serviceTransactionTypes: string[]; }): Location | undefined { - const { transactionType } = urlParams; - const firstTransactionType = first(serviceTransactionTypes); + const transactionTypeFromUrlParams = urlParams.transactionType; - if (!transactionType && firstTransactionType) { + if (!transactionTypeFromUrlParams && transactionType) { return { ...location, search: fromQuery({ ...toQuery(location.search), - transactionType: firstTransactionType, + transactionType, }), }; } @@ -70,19 +69,11 @@ interface TransactionOverviewProps { export function TransactionOverview({ serviceName }: TransactionOverviewProps) { const location = useLocation(); const { urlParams } = useUrlParams(); - const { transactionType } = urlParams; - - // TODO: fetching of transaction types should perhaps be lifted since it is needed in several places. Context? + const transactionType = useTransactionType(); const serviceTransactionTypes = useServiceTransactionTypes(urlParams); // redirect to first transaction type - useRedirect( - getRedirectLocation({ - urlParams, - location, - serviceTransactionTypes, - }) - ); + useRedirect(getRedirectLocation({ location, transactionType, urlParams })); const { data: transactionCharts, diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx rename to x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/useRedirect.ts b/x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/useRedirect.ts rename to x-pack/plugins/apm/public/components/app/transaction_overview/useRedirect.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/user_experience_callout.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/user_experience_callout.tsx rename to x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index 6f1f4e01c4d1..73a819af2d62 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -46,17 +46,7 @@ export function SparkPlot(props: Props) { return ( - + (); + const chartTheme = useChartTheme(); const { event, setEvent } = useChartsSync(); const { urlParams } = useUrlParams(); const { start, end } = urlParams; @@ -74,13 +75,6 @@ export function TimeseriesChart({ const xFormatter = niceTimeFormatter([min, max]); - const chartTheme: SettingsSpec['theme'] = { - lineSeriesStyle: { - point: { visible: false }, - line: { strokeWidth: 2 }, - }, - }; - const isEmpty = timeseries .map((serie) => serie.data) .flat() diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/index.tsx similarity index 66% rename from x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/index.tsx index 9b0c041aaf7b..4d9a1637bea7 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/index.tsx @@ -6,10 +6,16 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { useTransactionBreakdown } from '../../../hooks/useTransactionBreakdown'; -import { TransactionBreakdownGraph } from './TransactionBreakdownGraph'; +import { useTransactionBreakdown } from '../../../../hooks/use_transaction_breakdown'; +import { TransactionBreakdownChartContents } from './transaction_breakdown_chart_contents'; -function TransactionBreakdown() { +export function TransactionBreakdownChart({ + height, + showAnnotations = true, +}: { + height?: number; + showAnnotations?: boolean; +}) { const { data, status } = useTransactionBreakdown(); const { timeseries } = data; @@ -20,20 +26,20 @@ function TransactionBreakdown() {

{i18n.translate('xpack.apm.transactionBreakdown.chartTitle', { - defaultMessage: 'Time spent by span type', + defaultMessage: 'Average duration by span type', })}

-
); } - -export { TransactionBreakdown }; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx similarity index 88% rename from x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx index 677e4b7593ff..8070868f831b 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx @@ -18,6 +18,7 @@ import { import moment from 'moment'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; +import { useChartTheme } from '../../../../../../observability/public'; import { asPercent } from '../../../../../common/utils/formatters'; import { TimeSeries } from '../../../../../typings/timeseries'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; @@ -28,16 +29,22 @@ import { Annotations } from '../../charts/annotations'; import { ChartContainer } from '../../charts/chart_container'; import { onBrushEnd } from '../../charts/helper/helper'; -const XY_HEIGHT = unit * 16; - interface Props { fetchStatus: FETCH_STATUS; + height?: number; + showAnnotations: boolean; timeseries?: TimeSeries[]; } -export function TransactionBreakdownGraph({ fetchStatus, timeseries }: Props) { +export function TransactionBreakdownChartContents({ + fetchStatus, + height = unit * 16, + showAnnotations, + timeseries, +}: Props) { const history = useHistory(); const chartRef = React.createRef(); + const chartTheme = useChartTheme(); const { event, setEvent } = useChartsSync2(); const { urlParams } = useUrlParams(); const { start, end } = urlParams; @@ -54,17 +61,14 @@ export function TransactionBreakdownGraph({ fetchStatus, timeseries }: Props) { const xFormatter = niceTimeFormatter([min, max]); return ( - + onBrushEnd({ x, history })} showLegend showLegendExtra legendPosition={Position.Bottom} + theme={chartTheme} xDomain={{ min, max }} flatLegend onPointerUpdate={(currEvent: any) => { @@ -87,7 +91,7 @@ export function TransactionBreakdownGraph({ fetchStatus, timeseries }: Props) { tickFormat={(y: number) => asPercent(y ?? 0, 1)} /> - + {showAnnotations && } {timeseries?.length ? ( timeseries.map((serie) => { diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index 41212aa7b982..61d834abda79 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -28,7 +28,7 @@ import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { ITransactionChartData } from '../../../../selectors/chart_selectors'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { TransactionBreakdown } from '../../TransactionBreakdown'; +import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; import { TimeseriesChart } from '../timeseries_chart'; import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; import { getResponseTimeTickFormatter } from './helper'; @@ -117,7 +117,7 @@ export function TransactionCharts({
- + diff --git a/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts b/x-pack/plugins/apm/public/hooks/use_transaction_breakdown.ts similarity index 84% rename from x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts rename to x-pack/plugins/apm/public/hooks/use_transaction_breakdown.ts index 148324768642..686501c1eef4 100644 --- a/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_breakdown.ts @@ -7,13 +7,13 @@ import { useParams } from 'react-router-dom'; import { useFetcher } from './useFetcher'; import { useUrlParams } from './useUrlParams'; +import { useTransactionType } from './use_transaction_type'; export function useTransactionBreakdown() { const { serviceName } = useParams<{ serviceName?: string }>(); - const { - urlParams: { start, end, transactionName, transactionType }, - uiFilters, - } = useUrlParams(); + const { urlParams, uiFilters } = useUrlParams(); + const { start, end, transactionName } = urlParams; + const transactionType = useTransactionType(); const { data = { timeseries: undefined }, error, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_type.ts b/x-pack/plugins/apm/public/hooks/use_transaction_type.ts new file mode 100644 index 000000000000..fd4e6516f9ca --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_transaction_type.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getFirstTransactionType } from '../../common/agent_name'; +import { useAgentName } from './useAgentName'; +import { useServiceTransactionTypes } from './useServiceTransactionTypes'; +import { useUrlParams } from './useUrlParams'; + +/** + * Get either the transaction type from the URL parameters, "request" + * (for non-RUM agents), "page-load" (for RUM agents) if this service uses them, + * or the first available transaction type. + */ +export function useTransactionType() { + const { agentName } = useAgentName(); + const { urlParams } = useUrlParams(); + const transactionTypeFromUrlParams = urlParams.transactionType; + const transactionTypes = useServiceTransactionTypes(urlParams); + const firstTransactionType = getFirstTransactionType( + transactionTypes, + agentName + ); + + return transactionTypeFromUrlParams ?? firstTransactionType; +} diff --git a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx index b5bfe3eec7d3..fe668189dcf5 100644 --- a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx +++ b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx @@ -8,5 +8,19 @@ import { useTheme } from './use_theme'; export function useChartTheme() { const theme = useTheme(); - return theme.darkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; + const baseChartTheme = theme.darkMode + ? EUI_CHARTS_THEME_DARK.theme + : EUI_CHARTS_THEME_LIGHT.theme; + + return { + ...baseChartTheme, + background: { + ...baseChartTheme.background, + color: 'transparent', + }, + lineSeriesStyle: { + ...baseChartTheme.lineSeriesStyle, + point: { visible: false }, + }, + }; } From a31445f940844eb8b471e1c90b042d2ea25af05e Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Tue, 24 Nov 2020 13:46:51 +0100 Subject: [PATCH 16/89] Fix advanced settings API integration tests on cloud (#84110) This PR fixes the advanced settings feature controls API integration tests for cloud and moves some deployment helper methods to a separate service. --- test/common/services/deployment.ts | 78 +++++++++++++++++++ test/common/services/index.ts | 2 + test/examples/state_sync/todo_app.ts | 3 +- .../apps/dashboard/url_field_formatter.ts | 3 +- .../functional/apps/discover/_shared_links.js | 3 +- test/functional/apps/home/_newsfeed.ts | 5 +- .../apps/management/_scripted_fields.js | 9 ++- .../functional/apps/visualize/_chart_types.ts | 5 +- test/functional/apps/visualize/index.ts | 6 +- test/functional/page_objects/common_page.ts | 48 ------------ test/functional/page_objects/home_page.ts | 3 +- .../test_suites/core_plugins/applications.ts | 3 +- .../test_suites/core_plugins/rendering.ts | 6 +- .../test_suites/core_plugins/top_nav.ts | 7 +- .../advanced_settings/feature_controls.ts | 44 +++++++---- .../monitoring/setup/metricbeat_migration.js | 3 +- .../functional/page_objects/security_page.ts | 3 +- .../functional/page_objects/status_page.ts | 6 +- .../functional/services/monitoring/no_data.js | 6 +- .../login_selector/auth_provider_hint.ts | 3 +- .../login_selector/basic_functionality.ts | 3 +- .../tests/oidc/url_capture.ts | 5 +- .../tests/saml/url_capture.ts | 5 +- 23 files changed, 159 insertions(+), 100 deletions(-) create mode 100644 test/common/services/deployment.ts diff --git a/test/common/services/deployment.ts b/test/common/services/deployment.ts new file mode 100644 index 000000000000..88389b57dd1d --- /dev/null +++ b/test/common/services/deployment.ts @@ -0,0 +1,78 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get } from 'lodash'; +import fetch from 'node-fetch'; +// @ts-ignore not TS yet +import getUrl from '../../../src/test_utils/get_url'; + +import { FtrProviderContext } from '../ftr_provider_context'; + +export function DeploymentProvider({ getService }: FtrProviderContext) { + const config = getService('config'); + + return { + /** + * Returns Kibana host URL + */ + getHostPort() { + return getUrl.baseUrl(config.get('servers.kibana')); + }, + + /** + * Returns ES host URL + */ + getEsHostPort() { + return getUrl.baseUrl(config.get('servers.elasticsearch')); + }, + + /** + * Helper to detect an OSS licensed Kibana + * Useful for functional testing in cloud environment + */ + async isOss() { + const baseUrl = this.getEsHostPort(); + const username = config.get('servers.elasticsearch.username'); + const password = config.get('servers.elasticsearch.password'); + const response = await fetch(baseUrl + '/_xpack', { + method: 'get', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'), + }, + }); + return response.status !== 200; + }, + + async isCloud(): Promise { + const baseUrl = this.getHostPort(); + const username = config.get('servers.kibana.username'); + const password = config.get('servers.kibana.password'); + const response = await fetch(baseUrl + '/api/stats?extended', { + method: 'get', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'), + }, + }); + const data = await response.json(); + return get(data, 'usage.cloud.is_cloud_enabled', false); + }, + }; +} diff --git a/test/common/services/index.ts b/test/common/services/index.ts index 0a714e9875c3..b9fa99995ce9 100644 --- a/test/common/services/index.ts +++ b/test/common/services/index.ts @@ -17,6 +17,7 @@ * under the License. */ +import { DeploymentProvider } from './deployment'; import { LegacyEsProvider } from './legacy_es'; import { ElasticsearchProvider } from './elasticsearch'; import { EsArchiverProvider } from './es_archiver'; @@ -26,6 +27,7 @@ import { RandomnessProvider } from './randomness'; import { SecurityServiceProvider } from './security'; export const services = { + deployment: DeploymentProvider, legacyEs: LegacyEsProvider, es: ElasticsearchProvider, esArchiver: EsArchiverProvider, diff --git a/test/examples/state_sync/todo_app.ts b/test/examples/state_sync/todo_app.ts index 1ac5376b9ed8..d29a533aa1af 100644 --- a/test/examples/state_sync/todo_app.ts +++ b/test/examples/state_sync/todo_app.ts @@ -29,6 +29,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const browser = getService('browser'); const PageObjects = getPageObjects(['common']); const log = getService('log'); + const deployment = getService('deployment'); describe('TODO app', () => { describe("TODO app with browser history (platform's ScopedHistory)", async () => { @@ -36,7 +37,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide let base: string; before(async () => { - base = await PageObjects.common.getHostPort(); + base = await deployment.getHostPort(); await PageObjects.common.navigateToApp(appId, { insertTimestamp: false }); }); diff --git a/test/functional/apps/dashboard/url_field_formatter.ts b/test/functional/apps/dashboard/url_field_formatter.ts index a18ad740681b..62cc1a7e9575 100644 --- a/test/functional/apps/dashboard/url_field_formatter.ts +++ b/test/functional/apps/dashboard/url_field_formatter.ts @@ -34,6 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const browser = getService('browser'); const fieldName = 'clientip'; + const deployment = getService('deployment'); const clickFieldAndCheckUrl = async (fieldLink: WebElementWrapper) => { const fieldValue = await fieldLink.getVisibleText(); @@ -42,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(windowHandlers.length).to.equal(2); await browser.switchToWindow(windowHandlers[1]); const currentUrl = await browser.getCurrentUrl(); - const fieldUrl = common.getHostPort() + '/app/' + fieldValue; + const fieldUrl = deployment.getHostPort() + '/app/' + fieldValue; expect(currentUrl).to.equal(fieldUrl); }; diff --git a/test/functional/apps/discover/_shared_links.js b/test/functional/apps/discover/_shared_links.js index 56c648562404..9cd92626f73b 100644 --- a/test/functional/apps/discover/_shared_links.js +++ b/test/functional/apps/discover/_shared_links.js @@ -27,13 +27,14 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['common', 'discover', 'share', 'timePicker']); const browser = getService('browser'); const toasts = getService('toasts'); + const deployment = getService('deployment'); // FLAKY: https://github.com/elastic/kibana/issues/80104 describe.skip('shared links', function describeIndexTests() { let baseUrl; async function setup({ storeStateInSessionStorage }) { - baseUrl = PageObjects.common.getHostPort(); + baseUrl = deployment.getHostPort(); log.debug('baseUrl = ' + baseUrl); // browsers don't show the ':port' if it's 80 or 443 so we have to // remove that part so we can get a match in the tests. diff --git a/test/functional/apps/home/_newsfeed.ts b/test/functional/apps/home/_newsfeed.ts index aabd243e48f2..4568ba2b47d8 100644 --- a/test/functional/apps/home/_newsfeed.ts +++ b/test/functional/apps/home/_newsfeed.ts @@ -22,7 +22,8 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const globalNav = getService('globalNav'); - const PageObjects = getPageObjects(['common', 'newsfeed']); + const deployment = getService('deployment'); + const PageObjects = getPageObjects(['newsfeed']); describe('Newsfeed', () => { before(async () => { @@ -48,7 +49,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('shows all news from newsfeed', async () => { const objects = await PageObjects.newsfeed.getNewsfeedList(); - if (await PageObjects.common.isOss()) { + if (await deployment.isOss()) { expect(objects).to.eql([ '21 June 2019\nYou are functionally testing the newsfeed widget with fixtures!\nSee test/common/fixtures/plugins/newsfeed/newsfeed_simulation\nGeneric feed-viewer could go here', '21 June 2019\nStaging too!\nHello world\nGeneric feed-viewer could go here', diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 5ca01f239e76..a2cc976f2312 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -44,6 +44,7 @@ export default function ({ getService, getPageObjects }) { const inspector = getService('inspector'); const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); + const deployment = getService('deployment'); const PageObjects = getPageObjects([ 'common', 'header', @@ -202,7 +203,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName); await PageObjects.header.waitUntilLoadingHasFinished(); - if (await PageObjects.common.isOss()) { + if (await deployment.isOss()) { // OSS renders a vertical bar chart and we check the data in the Inspect panel const expectedChartValues = [ ['14', '31'], @@ -318,7 +319,7 @@ export default function ({ getService, getPageObjects }) { it('should visualize scripted field in vertical bar chart', async function () { await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2); await PageObjects.header.waitUntilLoadingHasFinished(); - if (await PageObjects.common.isOss()) { + if (await deployment.isOss()) { // OSS renders a vertical bar chart and we check the data in the Inspect panel await inspector.open(); await inspector.expectTableData([ @@ -414,7 +415,7 @@ export default function ({ getService, getPageObjects }) { it('should visualize scripted field in vertical bar chart', async function () { await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2); await PageObjects.header.waitUntilLoadingHasFinished(); - if (await PageObjects.common.isOss()) { + if (await deployment.isOss()) { // OSS renders a vertical bar chart and we check the data in the Inspect panel await inspector.open(); await inspector.expectTableData([ @@ -514,7 +515,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2); await PageObjects.header.waitUntilLoadingHasFinished(); - if (await PageObjects.common.isOss()) { + if (await deployment.isOss()) { // OSS renders a vertical bar chart and we check the data in the Inspect panel await inspector.open(); await inspector.setTablePageSize(50); diff --git a/test/functional/apps/visualize/_chart_types.ts b/test/functional/apps/visualize/_chart_types.ts index d3949b36591a..b404b74039be 100644 --- a/test/functional/apps/visualize/_chart_types.ts +++ b/test/functional/apps/visualize/_chart_types.ts @@ -22,14 +22,15 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { + const deployment = getService('deployment'); const log = getService('log'); - const PageObjects = getPageObjects(['common', 'visualize']); + const PageObjects = getPageObjects(['visualize']); let isOss = true; describe('chart types', function () { before(async function () { log.debug('navigateToApp visualize'); - isOss = await PageObjects.common.isOss(); + isOss = await deployment.isOss(); await PageObjects.visualize.navigateToNewVisualization(); }); diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index a30517519820..de73b2deabbd 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -20,12 +20,12 @@ import { FtrProviderContext } from '../../ftr_provider_context.d'; import { UI_SETTINGS } from '../../../../src/plugins/data/common'; -export default function ({ getService, getPageObjects, loadTestFile }: FtrProviderContext) { +export default function ({ getService, loadTestFile }: FtrProviderContext) { const browser = getService('browser'); const log = getService('log'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['common']); + const deployment = getService('deployment'); let isOss = true; describe('visualize app', () => { @@ -39,7 +39,7 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid defaultIndex: 'logstash-*', [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', }); - isOss = await PageObjects.common.isOss(); + isOss = await deployment.isOss(); }); describe('', function () { diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 4a14d43aec24..19f35ee3083b 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -19,7 +19,6 @@ import { delay } from 'bluebird'; import expect from '@kbn/expect'; -import { get } from 'lodash'; // @ts-ignore import fetch from 'node-fetch'; import { FtrProviderContext } from '../ftr_provider_context'; @@ -48,20 +47,6 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo } class CommonPage { - /** - * Returns Kibana host URL - */ - public getHostPort() { - return getUrl.baseUrl(config.get('servers.kibana')); - } - - /** - * Returns ES host URL - */ - public getEsHostPort() { - return getUrl.baseUrl(config.get('servers.elasticsearch')); - } - /** * Logins to Kibana as default user and navigates to provided app * @param appUrl Kibana URL @@ -455,39 +440,6 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo return await body.getVisibleText(); } - /** - * Helper to detect an OSS licensed Kibana - * Useful for functional testing in cloud environment - */ - async isOss() { - const baseUrl = this.getEsHostPort(); - const username = config.get('servers.elasticsearch.username'); - const password = config.get('servers.elasticsearch.password'); - const response = await fetch(baseUrl + '/_xpack', { - method: 'get', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'), - }, - }); - return response.status !== 200; - } - - async isCloud(): Promise { - const baseUrl = this.getHostPort(); - const username = config.get('servers.kibana.username'); - const password = config.get('servers.kibana.password'); - const response = await fetch(baseUrl + '/api/stats?extended', { - method: 'get', - headers: { - 'Content-Type': 'application/json', - Authorization: 'Basic ' + Buffer.from(username + ':' + password).toString('base64'), - }, - }); - const data = await response.json(); - return get(data, 'usage.cloud.is_cloud_enabled', false); - } - async waitForSaveModalToClose() { log.debug('Waiting for save modal to close'); await retry.try(async () => { diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index c12c633926c1..7f1db636de32 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -23,6 +23,7 @@ export function HomePageProvider({ getService, getPageObjects }: FtrProviderCont const testSubjects = getService('testSubjects'); const retry = getService('retry'); const find = getService('find'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['common']); let isOss = true; @@ -82,7 +83,7 @@ export function HomePageProvider({ getService, getPageObjects }: FtrProviderCont async launchSampleDashboard(id: string) { await this.launchSampleDataSet(id); - isOss = await PageObjects.common.isOss(); + isOss = await deployment.isOss(); if (!isOss) { await find.clickByLinkText('Dashboard'); } diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index 4c72c091a2be..d18fa31b0694 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -28,6 +28,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const testSubjects = getService('testSubjects'); const find = getService('find'); const retry = getService('retry'); + const deployment = getService('deployment'); const loadingScreenNotShown = async () => expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false); @@ -55,7 +56,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide }; const navigateTo = async (path: string) => - await browser.navigateTo(`${PageObjects.common.getHostPort()}${path}`); + await browser.navigateTo(`${deployment.getHostPort()}${path}`); describe('ui applications', function describeIndexTests() { before(async () => { diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 9931a3fabcd8..781e364996a5 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -32,15 +32,15 @@ declare global { } } -export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { - const PageObjects = getPageObjects(['common']); +export default function ({ getService }: PluginFunctionalProviderContext) { const appsMenu = getService('appsMenu'); const browser = getService('browser'); + const deployment = getService('deployment'); const find = getService('find'); const testSubjects = getService('testSubjects'); const navigateTo = async (path: string) => - await browser.navigateTo(`${PageObjects.common.getHostPort()}${path}`); + await browser.navigateTo(`${deployment.getHostPort()}${path}`); const navigateToApp = async (title: string) => { await appsMenu.clickLink(title); return browser.execute(() => { diff --git a/test/plugin_functional/test_suites/core_plugins/top_nav.ts b/test/plugin_functional/test_suites/core_plugins/top_nav.ts index c679ac89f2f6..9420ee2911b9 100644 --- a/test/plugin_functional/test_suites/core_plugins/top_nav.ts +++ b/test/plugin_functional/test_suites/core_plugins/top_nav.ts @@ -19,15 +19,14 @@ import expect from '@kbn/expect'; import { PluginFunctionalProviderContext } from '../../services'; -export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { - const PageObjects = getPageObjects(['common']); - +export default function ({ getService }: PluginFunctionalProviderContext) { const browser = getService('browser'); + const deployment = getService('deployment'); const testSubjects = getService('testSubjects'); describe.skip('top nav', function describeIndexTests() { before(async () => { - const url = `${PageObjects.common.getHostPort()}/app/kbn_tp_top_nav/`; + const url = `${deployment.getHostPort()}/app/kbn_tp_top_nav/`; await browser.get(url); }); diff --git a/x-pack/test/api_integration/apis/management/advanced_settings/feature_controls.ts b/x-pack/test/api_integration/apis/management/advanced_settings/feature_controls.ts index 7a0d0fe2f5d4..d4dae769662d 100644 --- a/x-pack/test/api_integration/apis/management/advanced_settings/feature_controls.ts +++ b/x-pack/test/api_integration/apis/management/advanced_settings/feature_controls.ts @@ -13,6 +13,27 @@ export default function featureControlsTests({ getService }: FtrProviderContext) const supertest: SuperTest = getService('supertestWithoutAuth'); const security = getService('security'); const spaces = getService('spaces'); + const deployment = getService('deployment'); + + async function expectTelemetryResponse(result: any, expectSuccess: boolean) { + if ((await deployment.isCloud()) === true) { + // Cloud deployments don't allow to change the opt-in status + expectTelemetryCloud400(result); + } else { + if (expectSuccess === true) { + expectResponse(result); + } else { + expect403(result); + } + } + } + + const expectTelemetryCloud400 = (result: any) => { + expect(result.error).to.be(undefined); + expect(result.response).not.to.be(undefined); + expect(result.response).to.have.property('statusCode', 400); + expect(result.response.body.message).to.be('{"error":"Not allowed to change Opt-in Status."}'); + }; const expect403 = (result: any) => { expect(result.error).to.be(undefined); @@ -21,16 +42,9 @@ export default function featureControlsTests({ getService }: FtrProviderContext) }; const expectResponse = (result: any) => { - if (result.response && result.response.statusCode === 400) { - // expect a change of telemetry settings to fail in cloud environment - expect(result.response.body.message).to.be( - '{"error":"Not allowed to change Opt-in Status."}' - ); - } else { - expect(result.error).to.be(undefined); - expect(result.response).not.to.be(undefined); - expect(result.response).to.have.property('statusCode', 200); - } + expect(result.error).to.be(undefined); + expect(result.response).not.to.be(undefined); + expect(result.response).to.have.property('statusCode', 200); }; async function saveAdvancedSetting(username: string, password: string, spaceId?: string) { @@ -83,7 +97,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expectResponse(regularSettingResult); const telemetryResult = await saveTelemetrySetting(username, password); - expectResponse(telemetryResult); + expectTelemetryResponse(telemetryResult, true); } finally { await security.role.delete(roleName); await security.user.delete(username); @@ -115,7 +129,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expect403(regularSettingResult); const telemetryResult = await saveTelemetrySetting(username, password); - expect403(telemetryResult); + expectTelemetryResponse(telemetryResult, false); } finally { await security.role.delete(roleName); await security.user.delete(username); @@ -189,7 +203,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expectResponse(regularSettingResult); const telemetryResult = await saveTelemetrySetting(username, password, space1Id); - expectResponse(telemetryResult); + expectTelemetryResponse(telemetryResult, true); }); it(`user_1 can only save telemetry in space_2`, async () => { @@ -197,7 +211,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expect403(regularSettingResult); const telemetryResult = await saveTelemetrySetting(username, password, space2Id); - expectResponse(telemetryResult); + expectTelemetryResponse(telemetryResult, true); }); it(`user_1 can't save either settings or telemetry in space_3`, async () => { @@ -205,7 +219,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) expect403(regularSettingResult); const telemetryResult = await saveTelemetrySetting(username, password, space3Id); - expect403(telemetryResult); + expectTelemetryResponse(telemetryResult, false); }); }); }); diff --git a/x-pack/test/functional/apps/monitoring/setup/metricbeat_migration.js b/x-pack/test/functional/apps/monitoring/setup/metricbeat_migration.js index 95bd866d386b..8a0c4216dfbd 100644 --- a/x-pack/test/functional/apps/monitoring/setup/metricbeat_migration.js +++ b/x-pack/test/functional/apps/monitoring/setup/metricbeat_migration.js @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { getLifecycleMethods } from '../_get_lifecycle_methods'; export default function ({ getService, getPageObjects }) { + const deployment = getService('deployment'); const setupMode = getService('monitoringSetupMode'); const PageObjects = getPageObjects(['common', 'console']); @@ -39,7 +40,7 @@ export default function ({ getService, getPageObjects }) { }); it('should not show metricbeat migration if cloud', async () => { - const isCloud = await PageObjects.common.isCloud(); + const isCloud = await deployment.isCloud(); expect(await setupMode.doesMetricbeatMigrationTooltipAppear()).to.be(!isCloud); }); diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index ef80ab475cbd..aca37d3d058e 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -18,6 +18,7 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider const userMenu = getService('userMenu'); const comboBox = getService('comboBox'); const supertest = getService('supertestWithoutAuth'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['common', 'header', 'error']); interface LoginOptions { @@ -248,7 +249,7 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider } log.debug('Redirecting to /logout to force the logout'); - const url = PageObjects.common.getHostPort() + '/logout'; + const url = deployment.getHostPort() + '/logout'; await browser.get(url); log.debug('Waiting on the login form to appear'); await waitForLoginPage(); diff --git a/x-pack/test/functional/page_objects/status_page.ts b/x-pack/test/functional/page_objects/status_page.ts index 08726e1320f2..c24b97b2b4fe 100644 --- a/x-pack/test/functional/page_objects/status_page.ts +++ b/x-pack/test/functional/page_objects/status_page.ts @@ -7,12 +7,12 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; -export function StatusPagePageProvider({ getService, getPageObjects }: FtrProviderContext) { +export function StatusPagePageProvider({ getService }: FtrProviderContext) { const retry = getService('retry'); const log = getService('log'); const browser = getService('browser'); const find = getService('find'); - const { common } = getPageObjects(['common']); + const deployment = getService('deployment'); class StatusPage { async initTests() { @@ -21,7 +21,7 @@ export function StatusPagePageProvider({ getService, getPageObjects }: FtrProvid async navigateToPage() { return await retry.try(async () => { - const url = common.getHostPort() + '/status'; + const url = deployment.getHostPort() + '/status'; log.info(`StatusPage:navigateToPage(): ${url}`); await browser.get(url); }); diff --git a/x-pack/test/functional/services/monitoring/no_data.js b/x-pack/test/functional/services/monitoring/no_data.js index 9cb383c8c625..f43f6cb4209c 100644 --- a/x-pack/test/functional/services/monitoring/no_data.js +++ b/x-pack/test/functional/services/monitoring/no_data.js @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export function MonitoringNoDataProvider({ getService, getPageObjects }) { +export function MonitoringNoDataProvider({ getService }) { + const deployment = getService('deployment'); const testSubjects = getService('testSubjects'); const retry = getService('retry'); - const PageObjects = getPageObjects(['common']); return new (class NoData { async enableMonitoring() { @@ -15,7 +15,7 @@ export function MonitoringNoDataProvider({ getService, getPageObjects }) { // so the UI does not give the user a choice between the two collection // methods. So if we're on cloud, do not try and switch to internal collection // as it's already the default - if (!(await PageObjects.common.isCloud())) { + if (!(await deployment.isCloud())) { await testSubjects.click('useInternalCollection'); } await testSubjects.click('enableCollectionEnabled'); diff --git a/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts b/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts index 8c2086255909..3e41fa918330 100644 --- a/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts +++ b/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const browser = getService('browser'); const security = getService('security'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['security', 'common']); describe('Authentication provider hint', function () { @@ -39,7 +40,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); beforeEach(async () => { - await browser.get(`${PageObjects.common.getHostPort()}/login`); + await browser.get(`${deployment.getHostPort()}/login`); await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); }); diff --git a/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts index a08fae4cdb0a..fe2dca806a6c 100644 --- a/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts +++ b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts @@ -13,6 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const browser = getService('browser'); const security = getService('security'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['security', 'common']); describe('Basic functionality', function () { @@ -33,7 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); beforeEach(async () => { - await browser.get(`${PageObjects.common.getHostPort()}/login`); + await browser.get(`${deployment.getHostPort()}/login`); await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); }); diff --git a/x-pack/test/security_functional/tests/oidc/url_capture.ts b/x-pack/test/security_functional/tests/oidc/url_capture.ts index bb4917f18fc1..a1afcd1e7602 100644 --- a/x-pack/test/security_functional/tests/oidc/url_capture.ts +++ b/x-pack/test/security_functional/tests/oidc/url_capture.ts @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const find = getService('find'); const browser = getService('browser'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['common']); describe('URL capture', function () { @@ -31,13 +32,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - await browser.get(PageObjects.common.getHostPort() + '/logout'); + await browser.get(deployment.getHostPort() + '/logout'); await PageObjects.common.waitUntilUrlIncludes('logged_out'); }); it('can login preserving original URL', async () => { await browser.get( - PageObjects.common.getHostPort() + '/app/management/security/users#some=hash-value' + deployment.getHostPort() + '/app/management/security/users#some=hash-value' ); await find.byCssSelector( diff --git a/x-pack/test/security_functional/tests/saml/url_capture.ts b/x-pack/test/security_functional/tests/saml/url_capture.ts index 5d47d80efadc..cdb0be694225 100644 --- a/x-pack/test/security_functional/tests/saml/url_capture.ts +++ b/x-pack/test/security_functional/tests/saml/url_capture.ts @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const find = getService('find'); const browser = getService('browser'); + const deployment = getService('deployment'); const PageObjects = getPageObjects(['common']); describe('URL capture', function () { @@ -31,13 +32,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); afterEach(async () => { - await browser.get(PageObjects.common.getHostPort() + '/logout'); + await browser.get(deployment.getHostPort() + '/logout'); await PageObjects.common.waitUntilUrlIncludes('logged_out'); }); it('can login preserving original URL', async () => { await browser.get( - PageObjects.common.getHostPort() + '/app/management/security/users#some=hash-value' + deployment.getHostPort() + '/app/management/security/users#some=hash-value' ); await find.byCssSelector( From fc9c19574ed67513f3dff1e79a8334723b9075e4 Mon Sep 17 00:00:00 2001 From: Diana Derevyankina <54894989+DziyanaDzeraviankina@users.noreply.github.com> Date: Tue, 24 Nov 2020 15:51:06 +0300 Subject: [PATCH 17/89] [Vega] Tutorials should be updated to include new inspector (#83797) * [Vega] Tutorials should be updated to include new inspector * Revert unnecessary changes * Add titles to the screenshots. paste the link to vega inspector and remove experimental caption * Update some captions * Update docs/user/dashboard/tutorials.asciidoc Co-authored-by: Wylie Conlon Co-authored-by: Wylie Conlon --- docs/user/dashboard/tutorials.asciidoc | 87 ++++++------------ .../visualize/images/vega_lite_tutorial_3.png | Bin 0 -> 18688 bytes .../visualize/images/vega_lite_tutorial_4.png | Bin 0 -> 65836 bytes .../visualize/images/vega_lite_tutorial_5.png | Bin 0 -> 87079 bytes .../visualize/images/vega_lite_tutorial_6.png | Bin 0 -> 73369 bytes .../visualize/images/vega_lite_tutorial_7.png | Bin 0 -> 175209 bytes 6 files changed, 28 insertions(+), 59 deletions(-) create mode 100644 docs/visualize/images/vega_lite_tutorial_3.png create mode 100644 docs/visualize/images/vega_lite_tutorial_4.png create mode 100644 docs/visualize/images/vega_lite_tutorial_5.png create mode 100644 docs/visualize/images/vega_lite_tutorial_6.png create mode 100644 docs/visualize/images/vega_lite_tutorial_7.png diff --git a/docs/user/dashboard/tutorials.asciidoc b/docs/user/dashboard/tutorials.asciidoc index b04de5fd0da6..d3abb849af81 100644 --- a/docs/user/dashboard/tutorials.asciidoc +++ b/docs/user/dashboard/tutorials.asciidoc @@ -76,8 +76,6 @@ Now that you've created your *Lens* visualization, add it to a <> set. @@ -98,7 +96,7 @@ line chart which shows the total number of documents across all your indices within the time range. [role="screenshot"] -image::visualize/images/vega_lite_default.png[] +image::visualize/images/vega_lite_default.png[Vega-Lite tutorial default visualization] The text editor contains a Vega-Lite spec written in https://hjson.github.io/[HJSON], which is similar to JSON but optimized for human editing. HJSON supports: @@ -134,7 +132,7 @@ Click "Update". The result is probably not what you expect. You should see a fla line with 0 results. You've only changed the index, so the difference must be the query is returning -no results. You can try the <>, +no results. You can try the <>, but intuition may be faster for this particular problem. In this case, the problem is that you are querying the field `@timestamp`, @@ -332,38 +330,29 @@ your spec: If you copy and paste that into your Vega-Lite spec, and click "Update", you will see a warning saying `Infinite extent for field "key": [Infinity, -Infinity]`. -Let's use our <> to understand why. +Let's use our <> to understand why. Vega-Lite generates data using the names `source_0` and `data_0`. `source_0` contains the results from the {es} query, and `data_0` contains the visually encoded results which are shown in the chart. To debug this problem, you need to compare both. -To look at the source, open the browser dev tools console and type -`VEGA_DEBUG.view.data('source_0')`. You will see: +To inspect data sets, go to *Inspect* and select *View: Vega debug*. You will see a menu with different data sources: -```js -[{ - doc_count: 454 - key: "Men's Clothing" - time_buckets: {buckets: Array(57)} - Symbol(vega_id): 12822 -}, ...] -``` +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_3.png[Data set selector showing root, source_0, data_0, and marks] -To compare to the visually encoded data, open the browser dev tools console and type -`VEGA_DEBUG.view.data('data_0')`. You will see: +To look closer at the raw data in Vega, select the option for `source_0` in the dropdown: -```js -[{ - doc_count: 454 - key: NaN - time_buckets: {buckets: Array(57)} - Symbol(vega_id): 13879 -}] -``` +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_4.png[Table for data_0 with columns key, doc_count and array of time_buckets] + +To compare to the visually encoded data, change the dropdown selection to `data_0`. You will see: + +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_5.png[Table for data_0 where the key is NaN instead of a string] The issue seems to be that the `key` property is not being converted the right way, -which makes sense because the `key` is now `Men's Clothing` instead of a timestamp. +which makes sense because the `key` is now category (`Men's Clothing`, `Women's Clothing`, etc.) instead of a timestamp. To fix this, try updating the `encoding` of your Vega-Lite spec to: @@ -382,21 +371,13 @@ To fix this, try updating the `encoding` of your Vega-Lite spec to: } ``` -This will show more errors, and you can inspect `VEGA_DEBUG.view.data('data_0')` to +This will show more errors, so you need to debug. Click *Inspect*, switch the view to *Vega Debug*, and switch to look at the visually encoded data in `data_0` to understand why. This now shows: -```js -[{ - doc_count: 454 - key: "Men's Clothing" - time_buckets: {buckets: Array(57)} - time_buckets.buckets.doc_count: undefined - time_buckets.buckets.key: null - Symbol(vega_id): 14094 -}] -``` +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_6.png[Table for data_0 showing that the column time_buckets.buckets.key is undefined] -It looks like the problem is that the `time_buckets` inner array is not being +It looks like the problem is that the `time_buckets.buckets` inner array is not being extracted by Vega. The solution is to use a Vega-lite https://vega.github.io/vega-lite/docs/flatten.html[flatten transformation], available in {kib} 7.9 and later. If using an older version of Kibana, the flatten transformation is available in Vega @@ -411,23 +392,10 @@ Add this section in between the `data` and `encoding` section: ``` This does not yet produce the results you expect. Inspect the transformed data -by typing `VEGA_DEBUG.view.data('data_0')` into the console again: +by selecting `data_0` in *Data sets* again: -```js -[{ - doc_count: 453 - key: "Men's Clothing" - time_bucket.buckets.doc_count: undefined - time_buckets: {buckets: Array(57)} - time_buckets.buckets: { - key_as_string: "2020-06-30T15:00:00.000Z", - key: 1593529200000, - doc_count: 2 - } - time_buckets.buckets.key: null - Symbol(vega_id): 21564 -}] -``` +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_7.png[Table showing data_0 with multiple pages of results, but undefined values in the column time_buckets.buckets.key] The debug view shows `undefined` values where you would expect to see numbers, and the cause is that there are duplicate names which are confusing Vega-Lite. This can @@ -564,7 +532,9 @@ Now that you've enabled a selection, try moving the mouse around the visualizati and seeing the points respond to the nearest position: [role="screenshot"] -image::visualize/images/vega_lite_tutorial_2.png[] +image::visualize/images/vega_lite_tutorial_2.png[Vega-Lite tutorial selection enabled] + +The selection is controlled by a Vega signal, and can be viewed using the <>. The final result of this tutorial is this spec: @@ -683,8 +653,6 @@ The final result of this tutorial is this spec: [[vega-tutorial-update-kibana-filters-from-vega]] === Update {kib} filters from Vega -experimental[] - In this tutorial you will build an area chart in Vega using an {es} search query, and add a click handler and drag handler to update {kib} filters. This tutorial is not a full https://vega.github.io/vega/tutorials/[Vega tutorial], @@ -935,6 +903,7 @@ The first step is to add a new `signal` to track the X position of the cursor: }] } ``` +To learn more about inspecting signals, explore the <>. Now add a new `mark` to indicate the current cursor position: @@ -1756,4 +1725,4 @@ Customize and format the visualization using functions: image::images/timelion-conditional04.png[] {nbsp} -For additional information on Timelion conditional capabilities, go to https://www.elastic.co/blog/timeseries-if-then-else-with-timelion[I have but one .condition()]. \ No newline at end of file +For additional information on Timelion conditional capabilities, go to https://www.elastic.co/blog/timeseries-if-then-else-with-timelion[I have but one .condition()]. diff --git a/docs/visualize/images/vega_lite_tutorial_3.png b/docs/visualize/images/vega_lite_tutorial_3.png new file mode 100644 index 0000000000000000000000000000000000000000..a294e02f078486f22b018f62acfd91272a4db2b5 GIT binary patch literal 18688 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 R8bB*FI+_L#tJNK!{V%zYo^AjD literal 0 HcmV?d00001 diff --git a/docs/visualize/images/vega_lite_tutorial_4.png b/docs/visualize/images/vega_lite_tutorial_4.png new file mode 100644 index 0000000000000000000000000000000000000000..e73a837fa816bbd40173e2db5ea1e92485ef00b1 GIT binary patch literal 65836 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 HO#=T5S>FO` literal 0 HcmV?d00001 diff --git a/docs/visualize/images/vega_lite_tutorial_5.png b/docs/visualize/images/vega_lite_tutorial_5.png new file mode 100644 index 0000000000000000000000000000000000000000..d0c84fe76ba55854a2ff853e8f7707e58e78779a GIT binary patch literal 87079 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%0D0ILq|C!3H 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 literal 0 HcmV?d00001 diff --git a/docs/visualize/images/vega_lite_tutorial_6.png b/docs/visualize/images/vega_lite_tutorial_6.png new file mode 100644 index 0000000000000000000000000000000000000000..486ef6c362438e752d38b3dfb2066bc45ddecde4 GIT binary patch literal 73369 zcmeEtcTf~f*e4Q21QbNcC^>`V3@UNSNX{9_Im51?fJn}g_xrBy>gukp?*6!{n<}W9Vd$Cee){R>7ba3eO@Z(pMESuG3_QE#J9uUmGk^4b3HMN#=Hoh6o@ak+yrm-WFGhO zss$WH@mKale)w}texvAoDUV(M**x{13n?@ce=a!m{kOxV+3v2(FCWp9eK1k{1e9x*p8RJMBa-n=Hrg`7f7@h@@^>5)GN^^No^m}GfQZ>{11ZktXd|N|Q*JZ6 zR2{k-6wh+I-^93kk1Y79%w6w(e^a4u*`aP6pH||U5EH>rDP=%>c9{m+PgVX=r<a827yAqyPW4<<3HJ#8IX@lyG?Nz{C0ElEWr z_eO)3296@`rWWzklt*@{tL4PiqaV8gBHw==-(*4Fr^IvL_RnzEe;V`dwVvs2sK4Iy zuTaNK#?Ijjd7X3*=OnNu6EM76;u6nGs$#0D^KmOx9*W2`KP*k96@SK>+qG7@tP75R zG?wE1&H%ynAI)VjArwO(~p=u=kPPs7zR0!>>3md3^zh-}{7&Y{j`jf#6LV4IY$ zSKVx(N-QHSfFH(ME!w82oJlcE%+BWC)WJujoy4tGp%QZiok2}+T6AriTb^tXWi%U@ z3YbJJeuwrF%@k?hPunmIb&cWvjnS>_^5rEBbSJF=C$v$T8* ziy?7mGn~s;JuR5@+@);j4iqTu`=oDoejdpq`+ZP=)|Isn!o5kI8O{(Oc*A)jLpEK+LNg76r(|3nSA?43Qd(CEP6FO9DwZ%YcdoZGzKw(;KEs@nV(467lxQ=Jz!-y~CW@DjG z3AfSbX_Wge3g-Av4!3!6cY43f$e2p)<%}7&-R+)Km5zRrLn}ErAY#lkkt%}oVUJY9 zdIu6~%K^hq@0on9mq~)LA$`1&{%emF@$gI9Bxn;gS zmcC1=*&e;;3(IP0xhA;>y_aa|Pa^y|g+<|QGRGr!qgck|N){HurCl!UvK-7s$7k_4 z{|4%#@?rW9aw)UY&Dp$nw9JE=%+xjbKF)^f5jrrto2~jA%(eK7(h!I6ZN-onB!4S7 zA-=;+nY!q{o3-sL?~^OSBW%FMT!IS_ajh}brvkj(-aYZIp0pNcJwv~F1>UElWytd| z@`@`yCn=I=*$S1(JKv87!u4DdKJhFLh4{KmX$=L1`yUIYK!9%{m1WmhMR(aiIMvcu<&N1o-vJx)!g%0A)a1ZIGzIPm?;H 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

  • LG+_&o@#hj*nL-8&(lTna6p#?qG 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 literal 0 HcmV?d00001 diff --git a/docs/visualize/images/vega_lite_tutorial_7.png b/docs/visualize/images/vega_lite_tutorial_7.png new file mode 100644 index 0000000000000000000000000000000000000000..d2c83371b107bcbcd7cabc392e148a6fc77b3085 GIT binary patch literal 175209 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 D9X;QC literal 0 HcmV?d00001 From e4ff981b944a646f459e5c248c6410a51e968d1a Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 24 Nov 2020 08:41:13 -0500 Subject: [PATCH 18/89] [Ingest Manager] UI to configure agent log level (#84112) --- .../plugins/fleet/common/services/routes.ts | 2 + .../fleet/hooks/use_request/agents.ts | 15 +++ .../components/agent_details.tsx | 11 ++ .../components/agent_logs/constants.tsx | 9 ++ .../agent_logs/filter_log_level.tsx | 10 +- .../components/agent_logs/index.tsx | 20 ++++ .../agent_logs/select_log_level.tsx | 110 ++++++++++++++++++ .../public/applications/fleet/types/index.ts | 2 + 8 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/select_log_level.tsx diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index f35b6b3f7de6..4af3f3beb32b 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -140,6 +140,8 @@ export const agentRouteService = { getBulkUpgradePath: () => AGENT_API_ROUTES.BULK_UPGRADE_PATTERN, getListPath: () => AGENT_API_ROUTES.LIST_PATTERN, getStatusPath: () => AGENT_API_ROUTES.STATUS_PATTERN, + getCreateActionPath: (agentId: string) => + AGENT_API_ROUTES.ACTIONS_PATTERN.replace('{agentId}', agentId), }; export const outputRoutesService = { diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts index 564e7b225cf4..7bbf621c5789 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts @@ -26,6 +26,8 @@ import { PostBulkAgentUpgradeRequest, PostAgentUpgradeResponse, PostBulkAgentUpgradeResponse, + PostNewAgentActionRequest, + PostNewAgentActionResponse, } from '../../types'; type RequestOptions = Pick, 'pollIntervalMs'>; @@ -144,6 +146,19 @@ export function sendPostAgentUpgrade( }); } +export function sendPostAgentAction( + agentId: string, + body: PostNewAgentActionRequest['body'], + options?: RequestOptions +) { + return sendRequest({ + path: agentRouteService.getCreateActionPath(agentId), + method: 'post', + body, + ...options, + }); +} + export function sendPostBulkAgentUpgrade( body: PostBulkAgentUpgradeRequest['body'], options?: RequestOptions diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details.tsx index 5ce757734e63..1b6ad35cc642 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_details.tsx @@ -109,6 +109,17 @@ export const AgentDetailsContent: React.FunctionComponent<{ : 'stable' : '-', }, + { + title: i18n.translate('xpack.fleet.agentDetails.logLevel', { + defaultMessage: 'Log level', + }), + description: + typeof agent.local_metadata.elastic === 'object' && + typeof agent.local_metadata.elastic.agent === 'object' && + typeof agent.local_metadata.elastic.agent.log_level === 'string' + ? agent.local_metadata.elastic.agent.log_level + : '-', + }, { title: i18n.translate('xpack.fleet.agentDetails.platformLabel', { defaultMessage: 'Platform', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx index b56e27356ef3..41069e710786 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx @@ -24,3 +24,12 @@ export const DEFAULT_DATE_RANGE = { start: 'now-1d', end: 'now', }; + +export const AGENT_LOG_LEVELS = { + ERROR: 'error', + WARNING: 'warning', + INFO: 'info', + DEBUG: 'debug', +}; + +export const DEFAULT_LOG_LEVEL = AGENT_LOG_LEVELS.INFO; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx index b034168dc8a1..a45831b2bbd2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx @@ -6,8 +6,10 @@ import React, { memo, useState, useEffect } from 'react'; import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { AGENT_LOG_LEVELS, AGENT_LOG_INDEX_PATTERN, LOG_LEVEL_FIELD } from './constants'; import { useStartServices } from '../../../../../hooks'; -import { AGENT_LOG_INDEX_PATTERN, LOG_LEVEL_FIELD } from './constants'; + +const LEVEL_VALUES = Object.values(AGENT_LOG_LEVELS); export const LogLevelFilter: React.FunctionComponent<{ selectedLevels: string[]; @@ -16,13 +18,13 @@ export const LogLevelFilter: React.FunctionComponent<{ const { data } = useStartServices(); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [levelValues, setLevelValues] = useState([]); + const [levelValues, setLevelValues] = useState(LEVEL_VALUES); useEffect(() => { const fetchValues = async () => { setIsLoading(true); try { - const values = await data.autocomplete.getValueSuggestions({ + const values: string[] = await data.autocomplete.getValueSuggestions({ indexPattern: { title: AGENT_LOG_INDEX_PATTERN, fields: [LOG_LEVEL_FIELD], @@ -30,7 +32,7 @@ export const LogLevelFilter: React.FunctionComponent<{ field: LOG_LEVEL_FIELD, query: '', }); - setLevelValues(values.sort()); + setLevelValues([...new Set([...LEVEL_VALUES, ...values.sort()])]); } catch (e) { setLevelValues([]); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx index e033781a850a..bed857c07309 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx @@ -17,6 +17,8 @@ import { EuiButtonEmpty, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import semverGte from 'semver/functions/gte'; +import semverCoerce from 'semver/functions/coerce'; import { RedirectAppLinks } from '../../../../../../../../../../../src/plugins/kibana_react/public'; import { TimeRange, esKuery } from '../../../../../../../../../../../src/plugins/data/public'; import { LogStream } from '../../../../../../../../../infra/public'; @@ -27,6 +29,7 @@ import { DatasetFilter } from './filter_dataset'; import { LogLevelFilter } from './filter_log_level'; import { LogQueryBar } from './query_bar'; import { buildQuery } from './build_query'; +import { SelectLogLevel } from './select_log_level'; const WrapperFlexGroup = styled(EuiFlexGroup)` height: 100%; @@ -137,6 +140,18 @@ export const AgentLogs: React.FunctionComponent<{ agent: Agent }> = memo(({ agen [logStreamQuery, dateRange.endExpression, dateRange.startExpression, http.basePath] ); + const agentVersion = agent.local_metadata?.elastic?.agent?.version; + const isLogLevelSelectionAvailable = useMemo(() => { + if (!agentVersion) { + return false; + } + const agentVersionWithPrerelease = semverCoerce(agentVersion)?.version; + if (!agentVersionWithPrerelease) { + return false; + } + return semverGte(agentVersionWithPrerelease, '7.11.0'); + }, [agentVersion]); + return ( @@ -213,6 +228,11 @@ export const AgentLogs: React.FunctionComponent<{ agent: Agent }> = memo(({ agen /> + {isLogLevelSelectionAvailable && ( + + + + )} ); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/select_log_level.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/select_log_level.tsx new file mode 100644 index 000000000000..7879c969d644 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/select_log_level.tsx @@ -0,0 +1,110 @@ +/* + * 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, { memo, useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSelect, EuiFormLabel, EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { Agent } from '../../../../../types'; +import { sendPostAgentAction, useStartServices } from '../../../../../hooks'; +import { AGENT_LOG_LEVELS, DEFAULT_LOG_LEVEL } from './constants'; + +const LEVEL_VALUES = Object.values(AGENT_LOG_LEVELS); + +export const SelectLogLevel: React.FC<{ agent: Agent }> = memo(({ agent }) => { + const { notifications } = useStartServices(); + const [isLoading, setIsLoading] = useState(false); + const [agentLogLevel, setAgentLogLevel] = useState( + agent.local_metadata?.elastic?.agent?.log_level ?? DEFAULT_LOG_LEVEL + ); + const [selectedLogLevel, setSelectedLogLevel] = useState(agentLogLevel); + + const onClickApply = useCallback(() => { + setIsLoading(true); + async function send() { + try { + const res = await sendPostAgentAction(agent.id, { + action: { + type: 'SETTINGS', + data: { + log_level: selectedLogLevel, + }, + }, + }); + if (res.error) { + throw res.error; + } + setAgentLogLevel(selectedLogLevel); + notifications.toasts.addSuccess( + i18n.translate('xpack.fleet.agentLogs.selectLogLevel.successText', { + defaultMessage: `Changed agent logging level to '{logLevel}'.`, + values: { + logLevel: selectedLogLevel, + }, + }) + ); + } catch (error) { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.agentLogs.selectLogLevel.errorTitleText', { + defaultMessage: 'Error updating agent logging level', + }), + }); + } + setIsLoading(false); + } + + send(); + }, [notifications, selectedLogLevel, agent.id]); + + return ( + + + + + + + + { + setSelectedLogLevel(event.target.value); + }} + options={LEVEL_VALUES.map((level) => ({ text: level, value: level }))} + /> + + {agentLogLevel !== selectedLogLevel && ( + + + {isLoading ? ( + + ) : ( + + )} + + + )} + + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts index 78cb355318d4..ded1447954af 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts @@ -67,6 +67,8 @@ export { PutAgentReassignResponse, PostBulkAgentReassignRequest, PostBulkAgentReassignResponse, + PostNewAgentActionResponse, + PostNewAgentActionRequest, // API schemas - Enrollment API Keys GetEnrollmentAPIKeysResponse, GetEnrollmentAPIKeysRequest, From b3d97764a0d942f2561ec33266d3d07f267a57a4 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 24 Nov 2020 17:19:18 +0300 Subject: [PATCH 19/89] Move streams to kbn/utils (#84033) * move streams to kbn/std * import streams from kbn/std * fix styles * remove unused shareWeakReplay * move from kbn/std to kbn/utils * import from subfolder since test mocks FS module and not compatible with kbn/utils * remove new line at the end of json file --- packages/kbn-es-archiver/package.json | 3 +- packages/kbn-es-archiver/src/actions/edit.ts | 3 +- packages/kbn-es-archiver/src/actions/load.ts | 2 +- .../src/actions/rebuild_all.ts | 3 +- packages/kbn-es-archiver/src/actions/save.ts | 2 +- .../kbn-es-archiver/src/actions/unload.ts | 2 +- .../src/lib/archives/__tests__/format.ts | 3 +- .../src/lib/archives/__tests__/parse.ts | 3 +- .../src/lib/archives/format.ts | 2 +- .../kbn-es-archiver/src/lib/archives/parse.ts | 2 +- .../__tests__/generate_doc_records_stream.ts | 3 +- .../__tests__/index_doc_records_stream.ts | 3 +- .../indices/__tests__/create_index_stream.ts | 3 +- .../indices/__tests__/delete_index_stream.ts | 2 +- .../generate_index_records_stream.ts | 3 +- .../__tests__/filter_records_stream.ts | 2 +- .../src/lib/streams/concat_stream.test.js | 74 ------ .../src/lib/streams/concat_stream.ts | 41 --- .../streams/concat_stream_providers.test.js | 66 ----- .../lib/streams/concat_stream_providers.ts | 60 ----- .../src/lib/streams/filter_stream.test.ts | 77 ------ .../src/lib/streams/filter_stream.ts | 33 --- .../lib/streams/intersperse_stream.test.js | 54 ---- .../src/lib/streams/intersperse_stream.ts | 61 ----- .../src/lib/streams/list_stream.test.js | 44 ---- .../src/lib/streams/list_stream.ts | 41 --- .../src/lib/streams/map_stream.test.js | 61 ----- .../src/lib/streams/map_stream.ts | 36 --- .../lib/streams/promise_from_streams.test.js | 136 ---------- .../src/lib/streams/promise_from_streams.ts | 64 ----- .../src/lib/streams/reduce_stream.test.js | 84 ------- .../src/lib/streams/reduce_stream.ts | 77 ------ .../src/lib/streams/replace_stream.test.js | 130 ---------- .../src/lib/streams/replace_stream.ts | 84 ------- .../src/lib/streams/split_stream.test.js | 71 ------ .../src/lib/streams/split_stream.ts | 71 ------ packages/kbn-legacy-logging/package.json | 2 +- .../src/log_format_json.test.ts | 2 +- .../src/log_format_string.test.ts | 2 +- .../src/test_utils/index.ts | 20 -- .../src/test_utils/streams.ts | 96 ------- packages/kbn-utils/src/index.ts | 1 + .../src}/streams/concat_stream.test.ts | 0 .../kbn-utils/src}/streams/concat_stream.ts | 0 .../streams/concat_stream_providers.test.ts | 0 .../src}/streams/concat_stream_providers.ts | 0 .../src}/streams/filter_stream.test.ts | 0 .../kbn-utils/src}/streams/filter_stream.ts | 0 .../lib => kbn-utils/src}/streams/index.ts | 0 .../src}/streams/intersperse_stream.test.ts | 0 .../src}/streams/intersperse_stream.ts | 0 .../src}/streams/list_stream.test.ts | 0 .../kbn-utils/src}/streams/list_stream.ts | 0 .../kbn-utils/src}/streams/map_stream.test.ts | 0 .../kbn-utils/src}/streams/map_stream.ts | 0 .../src}/streams/promise_from_streams.test.ts | 0 .../src}/streams/promise_from_streams.ts | 0 .../src}/streams/reduce_stream.test.ts | 0 .../kbn-utils/src}/streams/reduce_stream.ts | 0 .../src}/streams/replace_stream.test.ts | 0 .../kbn-utils/src}/streams/replace_stream.ts | 0 .../src}/streams/split_stream.test.ts | 0 .../kbn-utils/src}/streams/split_stream.ts | 0 src/cli_keystore/add.js | 3 +- src/core/public/utils/index.ts | 1 - .../public/utils/share_weak_replay.test.ts | 237 ------------------ src/core/public/utils/share_weak_replay.ts | 66 ----- .../get_sorted_objects_for_export.test.ts | 2 +- .../export/get_sorted_objects_for_export.ts | 2 +- .../import/collect_saved_objects.ts | 3 +- .../import/create_limit_stream.test.ts | 6 +- .../server/saved_objects/routes/export.ts | 3 +- .../routes/integration_tests/export.test.ts | 4 +- .../server/saved_objects/routes/utils.test.ts | 2 +- src/core/server/saved_objects/routes/utils.ts | 2 +- src/core/server/utils/index.ts | 1 - src/core/server/utils/streams/index.ts | 29 --- src/dev/build/lib/watch_stdio_for_line.ts | 6 +- .../routes/rules/import_rules_route.ts | 2 +- .../routes/rules/utils.test.ts | 3 +- .../create_rules_stream_from_ndjson.test.ts | 2 +- .../rules/create_rules_stream_from_ndjson.ts | 7 +- .../create_timelines_stream_from_ndjson.ts | 6 +- .../routes/import_timelines_route.test.ts | 4 +- .../lib/timeline/routes/utils/common.ts | 4 +- .../timeline/routes/utils/import_timelines.ts | 2 +- .../utils/install_prepacked_timelines.test.ts | 2 +- .../read_stream/create_stream_from_ndjson.ts | 3 +- .../create_copy_to_space_mocks.ts | 2 +- 89 files changed, 50 insertions(+), 1883 deletions(-) delete mode 100644 packages/kbn-es-archiver/src/lib/streams/concat_stream.test.js delete mode 100644 packages/kbn-es-archiver/src/lib/streams/concat_stream.ts delete mode 100644 packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.test.js delete mode 100644 packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.ts delete mode 100644 packages/kbn-es-archiver/src/lib/streams/filter_stream.test.ts delete mode 100644 packages/kbn-es-archiver/src/lib/streams/filter_stream.ts delete mode 100644 packages/kbn-es-archiver/src/lib/streams/intersperse_stream.test.js delete mode 100644 packages/kbn-es-archiver/src/lib/streams/intersperse_stream.ts delete mode 100644 packages/kbn-es-archiver/src/lib/streams/list_stream.test.js delete mode 100644 packages/kbn-es-archiver/src/lib/streams/list_stream.ts delete mode 100644 packages/kbn-es-archiver/src/lib/streams/map_stream.test.js delete mode 100644 packages/kbn-es-archiver/src/lib/streams/map_stream.ts delete mode 100644 packages/kbn-es-archiver/src/lib/streams/promise_from_streams.test.js delete mode 100644 packages/kbn-es-archiver/src/lib/streams/promise_from_streams.ts delete mode 100644 packages/kbn-es-archiver/src/lib/streams/reduce_stream.test.js delete mode 100644 packages/kbn-es-archiver/src/lib/streams/reduce_stream.ts delete mode 100644 packages/kbn-es-archiver/src/lib/streams/replace_stream.test.js delete mode 100644 packages/kbn-es-archiver/src/lib/streams/replace_stream.ts delete mode 100644 packages/kbn-es-archiver/src/lib/streams/split_stream.test.js delete mode 100644 packages/kbn-es-archiver/src/lib/streams/split_stream.ts delete mode 100644 packages/kbn-legacy-logging/src/test_utils/index.ts delete mode 100644 packages/kbn-legacy-logging/src/test_utils/streams.ts rename {src/core/server/utils => packages/kbn-utils/src}/streams/concat_stream.test.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/concat_stream.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/concat_stream_providers.test.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/concat_stream_providers.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/filter_stream.test.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/filter_stream.ts (100%) rename packages/{kbn-es-archiver/src/lib => kbn-utils/src}/streams/index.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/intersperse_stream.test.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/intersperse_stream.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/list_stream.test.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/list_stream.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/map_stream.test.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/map_stream.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/promise_from_streams.test.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/promise_from_streams.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/reduce_stream.test.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/reduce_stream.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/replace_stream.test.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/replace_stream.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/split_stream.test.ts (100%) rename {src/core/server/utils => packages/kbn-utils/src}/streams/split_stream.ts (100%) delete mode 100644 src/core/public/utils/share_weak_replay.test.ts delete mode 100644 src/core/public/utils/share_weak_replay.ts delete mode 100644 src/core/server/utils/streams/index.ts diff --git a/packages/kbn-es-archiver/package.json b/packages/kbn-es-archiver/package.json index 3cd07668635f..fed484401115 100644 --- a/packages/kbn-es-archiver/package.json +++ b/packages/kbn-es-archiver/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@kbn/dev-utils": "link:../kbn-dev-utils", - "@kbn/test": "link:../kbn-test" + "@kbn/test": "link:../kbn-test", + "@kbn/utils": "link:../kbn-utils" } } \ No newline at end of file diff --git a/packages/kbn-es-archiver/src/actions/edit.ts b/packages/kbn-es-archiver/src/actions/edit.ts index 1194637b1ff8..9a270fd3820f 100644 --- a/packages/kbn-es-archiver/src/actions/edit.ts +++ b/packages/kbn-es-archiver/src/actions/edit.ts @@ -23,8 +23,7 @@ import { createGunzip, createGzip, Z_BEST_COMPRESSION } from 'zlib'; import { promisify } from 'util'; import globby from 'globby'; import { ToolingLog } from '@kbn/dev-utils'; - -import { createPromiseFromStreams } from '../lib/streams'; +import { createPromiseFromStreams } from '@kbn/utils'; const unlinkAsync = promisify(Fs.unlink); diff --git a/packages/kbn-es-archiver/src/actions/load.ts b/packages/kbn-es-archiver/src/actions/load.ts index c2f5f18a07e9..11d47437126b 100644 --- a/packages/kbn-es-archiver/src/actions/load.ts +++ b/packages/kbn-es-archiver/src/actions/load.ts @@ -23,7 +23,7 @@ import { Readable } from 'stream'; import { ToolingLog, KbnClient } from '@kbn/dev-utils'; import { Client } from 'elasticsearch'; -import { createPromiseFromStreams, concatStreamProviders } from '../lib/streams'; +import { createPromiseFromStreams, concatStreamProviders } from '@kbn/utils'; import { isGzip, diff --git a/packages/kbn-es-archiver/src/actions/rebuild_all.ts b/packages/kbn-es-archiver/src/actions/rebuild_all.ts index 470a566a6eef..8abc24d52704 100644 --- a/packages/kbn-es-archiver/src/actions/rebuild_all.ts +++ b/packages/kbn-es-archiver/src/actions/rebuild_all.ts @@ -22,8 +22,7 @@ import { stat, Stats, rename, createReadStream, createWriteStream } from 'fs'; import { Readable, Writable } from 'stream'; import { fromNode } from 'bluebird'; import { ToolingLog } from '@kbn/dev-utils'; - -import { createPromiseFromStreams } from '../lib/streams'; +import { createPromiseFromStreams } from '@kbn/utils'; import { prioritizeMappings, readDirectory, diff --git a/packages/kbn-es-archiver/src/actions/save.ts b/packages/kbn-es-archiver/src/actions/save.ts index 84a0ce09936d..60a04a6123c9 100644 --- a/packages/kbn-es-archiver/src/actions/save.ts +++ b/packages/kbn-es-archiver/src/actions/save.ts @@ -22,8 +22,8 @@ import { createWriteStream, mkdirSync } from 'fs'; import { Readable, Writable } from 'stream'; import { Client } from 'elasticsearch'; import { ToolingLog } from '@kbn/dev-utils'; +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; -import { createListStream, createPromiseFromStreams } from '../lib/streams'; import { createStats, createGenerateIndexRecordsStream, diff --git a/packages/kbn-es-archiver/src/actions/unload.ts b/packages/kbn-es-archiver/src/actions/unload.ts index ae23ef21fb79..915f0906eb0d 100644 --- a/packages/kbn-es-archiver/src/actions/unload.ts +++ b/packages/kbn-es-archiver/src/actions/unload.ts @@ -22,8 +22,8 @@ import { createReadStream } from 'fs'; import { Readable, Writable } from 'stream'; import { Client } from 'elasticsearch'; import { ToolingLog, KbnClient } from '@kbn/dev-utils'; +import { createPromiseFromStreams } from '@kbn/utils'; -import { createPromiseFromStreams } from '../lib/streams'; import { isGzip, createStats, diff --git a/packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts b/packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts index 044a0e82d9df..91c38d0dd143 100644 --- a/packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts +++ b/packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts @@ -21,8 +21,7 @@ import Stream, { Readable, Writable } from 'stream'; import { createGunzip } from 'zlib'; import expect from '@kbn/expect'; - -import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '@kbn/utils'; import { createFormatArchiveStreams } from '../format'; diff --git a/packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts b/packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts index 25b8fe46a81f..deaea5cd4532 100644 --- a/packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts +++ b/packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts @@ -21,8 +21,7 @@ import Stream, { PassThrough, Readable, Writable, Transform } from 'stream'; import { createGzip } from 'zlib'; import expect from '@kbn/expect'; - -import { createConcatStream, createListStream, createPromiseFromStreams } from '../../streams'; +import { createConcatStream, createListStream, createPromiseFromStreams } from '@kbn/utils'; import { createParseArchiveStreams } from '../parse'; diff --git a/packages/kbn-es-archiver/src/lib/archives/format.ts b/packages/kbn-es-archiver/src/lib/archives/format.ts index 3cd698c3f82c..74c9561407c8 100644 --- a/packages/kbn-es-archiver/src/lib/archives/format.ts +++ b/packages/kbn-es-archiver/src/lib/archives/format.ts @@ -21,7 +21,7 @@ import { createGzip, Z_BEST_COMPRESSION } from 'zlib'; import { PassThrough } from 'stream'; import stringify from 'json-stable-stringify'; -import { createMapStream, createIntersperseStream } from '../streams'; +import { createMapStream, createIntersperseStream } from '@kbn/utils'; import { RECORD_SEPARATOR } from './constants'; export function createFormatArchiveStreams({ gzip = false }: { gzip?: boolean } = {}) { diff --git a/packages/kbn-es-archiver/src/lib/archives/parse.ts b/packages/kbn-es-archiver/src/lib/archives/parse.ts index 9236a618aa01..65b01f38eb83 100644 --- a/packages/kbn-es-archiver/src/lib/archives/parse.ts +++ b/packages/kbn-es-archiver/src/lib/archives/parse.ts @@ -24,7 +24,7 @@ import { createSplitStream, createReplaceStream, createMapStream, -} from '../streams'; +} from '@kbn/utils'; import { RECORD_SEPARATOR } from './constants'; diff --git a/packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts b/packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts index 3c5fc742a6e1..074333eb6028 100644 --- a/packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts @@ -20,8 +20,7 @@ import sinon from 'sinon'; import expect from '@kbn/expect'; import { delay } from 'bluebird'; - -import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '@kbn/utils'; import { createGenerateDocRecordsStream } from '../generate_doc_records_stream'; import { Progress } from '../../progress'; diff --git a/packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts b/packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts index 2b8eac5c2712..ac85681610c6 100644 --- a/packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts @@ -19,8 +19,7 @@ import expect from '@kbn/expect'; import { delay } from 'bluebird'; - -import { createListStream, createPromiseFromStreams } from '../../streams'; +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; import { Progress } from '../../progress'; import { createIndexDocRecordsStream } from '../index_doc_records_stream'; diff --git a/packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts b/packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts index 27c28b2229ae..b1a83046f40d 100644 --- a/packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts @@ -20,8 +20,7 @@ import expect from '@kbn/expect'; import sinon from 'sinon'; import Chance from 'chance'; - -import { createPromiseFromStreams, createConcatStream, createListStream } from '../../streams'; +import { createPromiseFromStreams, createConcatStream, createListStream } from '@kbn/utils'; import { createCreateIndexStream } from '../create_index_stream'; diff --git a/packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts b/packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts index 551b744415c8..3c9d86670000 100644 --- a/packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts @@ -19,7 +19,7 @@ import sinon from 'sinon'; -import { createListStream, createPromiseFromStreams } from '../../streams'; +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; import { createDeleteIndexStream } from '../delete_index_stream'; diff --git a/packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts b/packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts index cb3746c015da..d2c9f1274e60 100644 --- a/packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts @@ -19,8 +19,7 @@ import sinon from 'sinon'; import expect from '@kbn/expect'; - -import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '@kbn/utils'; import { createStubClient, createStubStats } from './stubs'; diff --git a/packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts b/packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts index b23ff2e4e52a..cf67ee2071c1 100644 --- a/packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts @@ -20,7 +20,7 @@ import Chance from 'chance'; import expect from '@kbn/expect'; -import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '@kbn/utils'; import { createFilterRecordsStream } from '../filter_records_stream'; diff --git a/packages/kbn-es-archiver/src/lib/streams/concat_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/concat_stream.test.js deleted file mode 100644 index 1498334013d1..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/concat_stream.test.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createListStream, createPromiseFromStreams, createConcatStream } from './'; - -describe('concatStream', () => { - test('accepts an initial value', async () => { - const output = await createPromiseFromStreams([ - createListStream([1, 2, 3]), - createConcatStream([0]), - ]); - - expect(output).toEqual([0, 1, 2, 3]); - }); - - describe(`combines using the previous value's concat method`, () => { - test('works with strings', async () => { - const output = await createPromiseFromStreams([ - createListStream(['a', 'b', 'c']), - createConcatStream(), - ]); - expect(output).toEqual('abc'); - }); - - test('works with arrays', async () => { - const output = await createPromiseFromStreams([ - createListStream([[1], [2, 3, 4], [10]]), - createConcatStream(), - ]); - expect(output).toEqual([1, 2, 3, 4, 10]); - }); - - test('works with a mixture, starting with array', async () => { - const output = await createPromiseFromStreams([ - createListStream([[], 1, 2, 3, 4, [5, 6, 7]]), - createConcatStream(), - ]); - expect(output).toEqual([1, 2, 3, 4, 5, 6, 7]); - }); - - test('fails when the value does not have a concat method', async () => { - let promise; - try { - promise = createPromiseFromStreams([createListStream([1, '1']), createConcatStream()]); - } catch (err) { - throw new Error('createPromiseFromStreams() should not fail synchronously'); - } - - try { - await promise; - throw new Error('Promise should have rejected'); - } catch (err) { - expect(err).toBeInstanceOf(Error); - expect(err.message).toContain('concat'); - } - }); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/concat_stream.ts b/packages/kbn-es-archiver/src/lib/streams/concat_stream.ts deleted file mode 100644 index 03dd894067af..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/concat_stream.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createReduceStream } from './reduce_stream'; - -/** - * Creates a Transform stream that consumes all provided - * values and concatenates them using each values `concat` - * method. - * - * Concatenate strings: - * createListStream(['f', 'o', 'o']) - * .pipe(createConcatStream()) - * .on('data', console.log) - * // logs "foo" - * - * Concatenate values into an array: - * createListStream([1,2,3]) - * .pipe(createConcatStream([])) - * .on('data', console.log) - * // logs "[1,2,3]" - */ -export function createConcatStream(initial: any) { - return createReduceStream((acc, chunk) => acc.concat(chunk), initial); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.test.js b/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.test.js deleted file mode 100644 index 878d645d9b4a..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.test.js +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Readable } from 'stream'; - -import { concatStreamProviders } from './concat_stream_providers'; -import { createListStream } from './list_stream'; -import { createConcatStream } from './concat_stream'; -import { createPromiseFromStreams } from './promise_from_streams'; - -describe('concatStreamProviders() helper', () => { - test('writes the data from an array of stream providers into a destination stream in order', async () => { - const results = await createPromiseFromStreams([ - concatStreamProviders([ - () => createListStream(['foo', 'bar']), - () => createListStream(['baz']), - () => createListStream(['bug']), - ]), - createConcatStream(''), - ]); - - expect(results).toBe('foobarbazbug'); - }); - - test('emits the errors from a sub-stream to the destination', async () => { - const dest = concatStreamProviders([ - () => createListStream(['foo', 'bar']), - () => - new Readable({ - read() { - this.destroy(new Error('foo')); - }, - }), - ]); - - const errorListener = jest.fn(); - dest.on('error', errorListener); - - await expect(createPromiseFromStreams([dest])).rejects.toThrowErrorMatchingInlineSnapshot( - `"foo"` - ); - expect(errorListener.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - [Error: foo], - ], -] -`); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.ts b/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.ts deleted file mode 100644 index be0768316b29..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/concat_stream_providers.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { PassThrough, TransformOptions } from 'stream'; - -/** - * Write the data and errors from a list of stream providers - * to a single stream in order. Stream providers are only - * called right before they will be consumed, and only one - * provider will be active at a time. - */ -export function concatStreamProviders( - sourceProviders: Array<() => NodeJS.ReadableStream>, - options: TransformOptions = {} -) { - const destination = new PassThrough(options); - const queue = sourceProviders.slice(); - - (function pipeNext() { - const provider = queue.shift(); - - if (!provider) { - return; - } - - const source = provider(); - const isLast = !queue.length; - - // if there are more sources to pipe, hook - // into the source completion - if (!isLast) { - source.once('end', pipeNext); - } - - source - // proxy errors from the source to the destination - .once('error', (error) => destination.destroy(error)) - // pipe the source to the destination but only proxy the - // end event if this is the last source - .pipe(destination, { end: isLast }); - })(); - - return destination; -} diff --git a/packages/kbn-es-archiver/src/lib/streams/filter_stream.test.ts b/packages/kbn-es-archiver/src/lib/streams/filter_stream.test.ts deleted file mode 100644 index 28b7f2588628..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/filter_stream.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - createConcatStream, - createFilterStream, - createListStream, - createPromiseFromStreams, -} from './'; - -describe('createFilterStream()', () => { - test('calls the function with each item in the source stream', async () => { - const filter = jest.fn().mockReturnValue(true); - - await createPromiseFromStreams([createListStream(['a', 'b', 'c']), createFilterStream(filter)]); - - expect(filter).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - "a", - ], - Array [ - "b", - ], - Array [ - "c", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": true, - }, - Object { - "type": "return", - "value": true, - }, - Object { - "type": "return", - "value": true, - }, - ], - } - `); - }); - - test('send the filtered values on the output stream', async () => { - const result = await createPromiseFromStreams([ - createListStream([1, 2, 3]), - createFilterStream((n) => n % 2 === 0), - createConcatStream([]), - ]); - - expect(result).toMatchInlineSnapshot(` - Array [ - 2, - ] - `); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/filter_stream.ts b/packages/kbn-es-archiver/src/lib/streams/filter_stream.ts deleted file mode 100644 index 738b9d5793d0..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/filter_stream.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -export function createFilterStream(fn: (obj: T) => boolean) { - return new Transform({ - objectMode: true, - async transform(obj, _, done) { - const canPushDownStream = fn(obj); - if (canPushDownStream) { - this.push(obj); - } - done(); - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.test.js deleted file mode 100644 index e11b36d77106..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.test.js +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - createPromiseFromStreams, - createListStream, - createIntersperseStream, - createConcatStream, -} from './'; - -describe('intersperseStream', () => { - test('places the intersperse value between each provided value', async () => { - expect( - await createPromiseFromStreams([ - createListStream(['to', 'be', 'or', 'not', 'to', 'be']), - createIntersperseStream(' '), - createConcatStream(), - ]) - ).toBe('to be or not to be'); - }); - - test('emits values as soon as possible, does not needlessly buffer', async () => { - const str = createIntersperseStream('y'); - const onData = jest.fn(); - str.on('data', onData); - - str.write('a'); - expect(onData).toHaveBeenCalledTimes(1); - expect(onData.mock.calls[0]).toEqual(['a']); - onData.mockClear(); - - str.write('b'); - expect(onData).toHaveBeenCalledTimes(2); - expect(onData.mock.calls[0]).toEqual(['y']); - expect(onData).toHaveBeenCalledTimes(2); - expect(onData.mock.calls[1]).toEqual(['b']); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.ts b/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.ts deleted file mode 100644 index eb2e3d3087d4..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/intersperse_stream.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -/** - * Create a Transform stream that receives values in object mode, - * and intersperses a chunk between each object received. - * - * This is useful for writing lists: - * - * createListStream(['foo', 'bar']) - * .pipe(createIntersperseStream('\n')) - * .pipe(process.stdout) // outputs "foo\nbar" - * - * Combine with a concat stream to get "join" like functionality: - * - * await createPromiseFromStreams([ - * createListStream(['foo', 'bar']), - * createIntersperseStream(' '), - * createConcatStream() - * ]) // produces a single value "foo bar" - */ -export function createIntersperseStream(intersperseChunk: any) { - let first = true; - - return new Transform({ - writableObjectMode: true, - readableObjectMode: true, - transform(chunk, _, callback) { - try { - if (first) { - first = false; - } else { - this.push(intersperseChunk); - } - - this.push(chunk); - callback(undefined); - } catch (err) { - callback(err); - } - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/list_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/list_stream.test.js deleted file mode 100644 index 12e20696b051..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/list_stream.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createListStream } from './'; - -describe('listStream', () => { - test('provides the values in the initial list', async () => { - const str = createListStream([1, 2, 3, 4]); - const onData = jest.fn(); - str.on('data', onData); - - await new Promise((resolve) => str.on('end', resolve)); - - expect(onData).toHaveBeenCalledTimes(4); - expect(onData.mock.calls[0]).toEqual([1]); - expect(onData.mock.calls[1]).toEqual([2]); - expect(onData.mock.calls[2]).toEqual([3]); - expect(onData.mock.calls[3]).toEqual([4]); - }); - - test('does not modify the list passed', async () => { - const list = [1, 2, 3, 4]; - const str = createListStream(list); - str.resume(); - await new Promise((resolve) => str.on('end', resolve)); - expect(list).toEqual([1, 2, 3, 4]); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/list_stream.ts b/packages/kbn-es-archiver/src/lib/streams/list_stream.ts deleted file mode 100644 index c061b969b3c0..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/list_stream.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Readable } from 'stream'; - -/** - * Create a Readable stream that provides the items - * from a list as objects to subscribers - */ -export function createListStream(items: any | any[] = []) { - const queue: any[] = [].concat(items); - - return new Readable({ - objectMode: true, - read(size) { - queue.splice(0, size).forEach((item) => { - this.push(item); - }); - - if (!queue.length) { - this.push(null); - } - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/map_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/map_stream.test.js deleted file mode 100644 index d86da178f0c1..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/map_stream.test.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { delay } from 'bluebird'; - -import { createPromiseFromStreams } from './promise_from_streams'; -import { createListStream } from './list_stream'; -import { createMapStream } from './map_stream'; -import { createConcatStream } from './concat_stream'; - -describe('createMapStream()', () => { - test('calls the function with each item in the source stream', async () => { - const mapper = jest.fn(); - - await createPromiseFromStreams([createListStream(['a', 'b', 'c']), createMapStream(mapper)]); - - expect(mapper).toHaveBeenCalledTimes(3); - expect(mapper).toHaveBeenCalledWith('a', 0); - expect(mapper).toHaveBeenCalledWith('b', 1); - expect(mapper).toHaveBeenCalledWith('c', 2); - }); - - test('send the return value from the mapper on the output stream', async () => { - const result = await createPromiseFromStreams([ - createListStream([1, 2, 3]), - createMapStream((n) => n * 100), - createConcatStream([]), - ]); - - expect(result).toEqual([100, 200, 300]); - }); - - test('supports async mappers', async () => { - const result = await createPromiseFromStreams([ - createListStream([1, 2, 3]), - createMapStream(async (n, i) => { - await delay(n); - return n * i; - }), - createConcatStream([]), - ]); - - expect(result).toEqual([0, 2, 6]); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/map_stream.ts b/packages/kbn-es-archiver/src/lib/streams/map_stream.ts deleted file mode 100644 index e88c512a3865..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/map_stream.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -export function createMapStream(fn: (chunk: any, i: number) => T | Promise) { - let i = 0; - - return new Transform({ - objectMode: true, - async transform(value, _, done) { - try { - this.push(await fn(value, i++)); - done(); - } catch (err) { - done(err); - } - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.test.js b/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.test.js deleted file mode 100644 index e4d9835106f1..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.test.js +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Readable, Writable, Duplex, Transform } from 'stream'; - -import { createListStream, createPromiseFromStreams, createReduceStream } from './'; - -describe('promiseFromStreams', () => { - test('pipes together an array of streams', async () => { - const str1 = createListStream([1, 2, 3]); - const str2 = createReduceStream((acc, n) => acc + n, 0); - const sumPromise = new Promise((resolve) => str2.once('data', resolve)); - createPromiseFromStreams([str1, str2]); - await new Promise((resolve) => str2.once('end', resolve)); - expect(await sumPromise).toBe(6); - }); - - describe('last stream is writable', () => { - test('waits for the last stream to finish writing', async () => { - let written = ''; - - await createPromiseFromStreams([ - createListStream(['a']), - new Writable({ - write(chunk, enc, cb) { - setTimeout(() => { - written += chunk; - cb(); - }, 100); - }, - }), - ]); - - expect(written).toBe('a'); - }); - - test('resolves to undefined', async () => { - const result = await createPromiseFromStreams([ - createListStream(['a']), - new Writable({ - write(chunk, enc, cb) { - cb(); - }, - }), - ]); - - expect(result).toBe(undefined); - }); - }); - - describe('last stream is readable', () => { - test(`resolves to it's final value`, async () => { - const result = await createPromiseFromStreams([createListStream(['a', 'b', 'c'])]); - - expect(result).toBe('c'); - }); - }); - - describe('last stream is duplex', () => { - test('waits for writing and resolves to final value', async () => { - let written = ''; - - const duplexReadQueue = []; - const duplexItemsToPush = ['foo', 'bar', null]; - const result = await createPromiseFromStreams([ - createListStream(['a', 'b', 'c']), - new Duplex({ - async read() { - const result = await duplexReadQueue.shift(); - this.push(result); - }, - - write(chunk, enc, cb) { - duplexReadQueue.push( - new Promise((resolve) => { - setTimeout(() => { - written += chunk; - cb(); - resolve(duplexItemsToPush.shift()); - }, 50); - }) - ); - }, - }).setEncoding('utf8'), - ]); - - expect(written).toEqual('abc'); - expect(result).toBe('bar'); - }); - }); - - describe('error handling', () => { - test('read stream gets destroyed when transform stream fails', async () => { - let destroyCalled = false; - const readStream = new Readable({ - read() { - this.push('a'); - this.push('b'); - this.push('c'); - this.push(null); - }, - destroy() { - destroyCalled = true; - }, - }); - const transformStream = new Transform({ - transform(chunk, enc, done) { - done(new Error('Test error')); - }, - }); - try { - await createPromiseFromStreams([readStream, transformStream]); - throw new Error('Should fail'); - } catch (e) { - expect(e.message).toBe('Test error'); - expect(destroyCalled).toBe(true); - } - }); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.ts b/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.ts deleted file mode 100644 index fefb18be1478..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/promise_from_streams.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * Take an array of streams, pipe the output - * from each one into the next, listening for - * errors from any of the streams, and then resolve - * the promise once the final stream has finished - * writing/reading. - * - * If the last stream is readable, it's final value - * will be provided as the promise value. - * - * Errors emitted from any stream will cause - * the promise to be rejected with that error. - */ - -import { pipeline, Writable } from 'stream'; -import { promisify } from 'util'; - -const asyncPipeline = promisify(pipeline); - -export async function createPromiseFromStreams(streams: any): Promise { - let finalChunk: any; - const last = streams[streams.length - 1]; - if (typeof last.read !== 'function' && streams.length === 1) { - // For a nicer error than what stream.pipeline throws - throw new Error('A minimum of 2 streams is required when a non-readable stream is given'); - } - if (typeof last.read === 'function') { - // We are pushing a writable stream to capture the last chunk - streams.push( - new Writable({ - // Use object mode even when "last" stream isn't. This allows to - // capture the last chunk as-is. - objectMode: true, - write(chunk, _, done) { - finalChunk = chunk; - done(); - }, - }) - ); - } - - await asyncPipeline(...(streams as [any])); - - return finalChunk; -} diff --git a/packages/kbn-es-archiver/src/lib/streams/reduce_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/reduce_stream.test.js deleted file mode 100644 index 2c073f67f82a..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/reduce_stream.test.js +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createReduceStream, createPromiseFromStreams, createListStream } from './'; - -const promiseFromEvent = (name, emitter) => - new Promise((resolve) => emitter.on(name, () => resolve(name))); - -describe('reduceStream', () => { - test('calls the reducer for each item provided', async () => { - const stub = jest.fn(); - await createPromiseFromStreams([ - createListStream([1, 2, 3]), - createReduceStream((val, chunk, enc) => { - stub(val, chunk, enc); - return chunk; - }, 0), - ]); - expect(stub).toHaveBeenCalledTimes(3); - expect(stub.mock.calls[0]).toEqual([0, 1, 'utf8']); - expect(stub.mock.calls[1]).toEqual([1, 2, 'utf8']); - expect(stub.mock.calls[2]).toEqual([2, 3, 'utf8']); - }); - - test('provides the return value of the last iteration of the reducer', async () => { - const result = await createPromiseFromStreams([ - createListStream('abcdefg'.split('')), - createReduceStream((acc) => acc + 1, 0), - ]); - expect(result).toBe(7); - }); - - test('emits an error if an iteration fails', async () => { - const reduce = createReduceStream((acc, i) => expect(i).toBe(1), 0); - const errorEvent = promiseFromEvent('error', reduce); - - reduce.write(1); - reduce.write(2); - reduce.resume(); - await errorEvent; - }); - - test('stops calling the reducer if an iteration fails, emits no data', async () => { - const reducer = jest.fn((acc, i) => { - if (i < 100) return acc + i; - else throw new Error(i); - }); - const reduce$ = createReduceStream(reducer, 0); - - const dataStub = jest.fn(); - const errorStub = jest.fn(); - reduce$.on('data', dataStub); - reduce$.on('error', errorStub); - const endEvent = promiseFromEvent('end', reduce$); - - reduce$.write(1); - reduce$.write(2); - reduce$.write(300); - reduce$.write(400); - reduce$.write(1000); - reduce$.end(); - - await endEvent; - expect(reducer).toHaveBeenCalledTimes(3); - expect(dataStub).toHaveBeenCalledTimes(0); - expect(errorStub).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/reduce_stream.ts b/packages/kbn-es-archiver/src/lib/streams/reduce_stream.ts deleted file mode 100644 index d9458e9a11c3..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/reduce_stream.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -/** - * Create a transform stream that consumes each chunk it receives - * and passes it to the reducer, which will return the new value - * for the stream. Once all chunks have been received the reduce - * stream provides the result of final call to the reducer to - * subscribers. - */ -export function createReduceStream( - reducer: (acc: any, chunk: any, env: string) => any, - initial: any -) { - let i = -1; - let value = initial; - - // if the reducer throws an error then the value is - // considered invalid and the stream will never provide - // it to subscribers. We will also stop calling the - // reducer for any new data that is provided to us - let failed = false; - - if (typeof reducer !== 'function') { - throw new TypeError('reducer must be a function'); - } - - return new Transform({ - readableObjectMode: true, - writableObjectMode: true, - async transform(chunk, enc, callback) { - try { - if (failed) { - return callback(); - } - - i += 1; - if (i === 0 && initial === undefined) { - value = chunk; - } else { - value = await reducer(value, chunk, enc); - } - - callback(); - } catch (err) { - failed = true; - callback(err); - } - }, - - flush(callback) { - if (!failed) { - this.push(value); - } - - callback(); - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/replace_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/replace_stream.test.js deleted file mode 100644 index 01b89f93e5af..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/replace_stream.test.js +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - createReplaceStream, - createConcatStream, - createPromiseFromStreams, - createListStream, - createMapStream, -} from './'; - -async function concatToString(streams) { - return await createPromiseFromStreams([ - ...streams, - createMapStream((buff) => buff.toString('utf8')), - createConcatStream(''), - ]); -} - -describe('replaceStream', () => { - test('produces buffers when it receives buffers', async () => { - const chunks = await createPromiseFromStreams([ - createListStream([Buffer.from('foo'), Buffer.from('bar')]), - createReplaceStream('o', '0'), - createConcatStream([]), - ]); - - chunks.forEach((chunk) => { - expect(chunk).toBeInstanceOf(Buffer); - }); - }); - - test('produces buffers when it receives strings', async () => { - const chunks = await createPromiseFromStreams([ - createListStream(['foo', 'bar']), - createReplaceStream('o', '0'), - createConcatStream([]), - ]); - - chunks.forEach((chunk) => { - expect(chunk).toBeInstanceOf(Buffer); - }); - }); - - test('expects toReplace to be a string', () => { - expect(() => createReplaceStream(Buffer.from('foo'))).toThrowError(/be a string/); - }); - - test('replaces multiple single-char instances in a single chunk', async () => { - expect( - await concatToString([ - createListStream([Buffer.from('f00 bar')]), - createReplaceStream('0', 'o'), - ]) - ).toBe('foo bar'); - }); - - test('replaces multiple single-char instances in multiple chunks', async () => { - expect( - await concatToString([ - createListStream([Buffer.from('f0'), Buffer.from('0 bar')]), - createReplaceStream('0', 'o'), - ]) - ).toBe('foo bar'); - }); - - test('replaces single multi-char instances in single chunks', async () => { - expect( - await concatToString([ - createListStream([Buffer.from('f0'), Buffer.from('0 bar')]), - createReplaceStream('0', 'o'), - ]) - ).toBe('foo bar'); - }); - - test('replaces multiple multi-char instances in single chunks', async () => { - expect( - await concatToString([ - createListStream([Buffer.from('foo ba'), Buffer.from('r b'), Buffer.from('az bar')]), - createReplaceStream('bar', '*'), - ]) - ).toBe('foo * baz *'); - }); - - test('replaces multi-char instance that stretches multiple chunks', async () => { - expect( - await concatToString([ - createListStream([ - Buffer.from('foo supe'), - Buffer.from('rcalifra'), - Buffer.from('gilistic'), - Buffer.from('expialid'), - Buffer.from('ocious bar'), - ]), - createReplaceStream('supercalifragilisticexpialidocious', '*'), - ]) - ).toBe('foo * bar'); - }); - - test('ignores missing multi-char instance', async () => { - expect( - await concatToString([ - createListStream([ - Buffer.from('foo supe'), - Buffer.from('rcalifra'), - Buffer.from('gili stic'), - Buffer.from('expialid'), - Buffer.from('ocious bar'), - ]), - createReplaceStream('supercalifragilisticexpialidocious', '*'), - ]) - ).toBe('foo supercalifragili sticexpialidocious bar'); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/replace_stream.ts b/packages/kbn-es-archiver/src/lib/streams/replace_stream.ts deleted file mode 100644 index fe2ba1fcdf31..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/replace_stream.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -export function createReplaceStream(toReplace: string, replacement: string) { - if (typeof toReplace !== 'string') { - throw new TypeError('toReplace must be a string'); - } - - let buffer = Buffer.alloc(0); - return new Transform({ - objectMode: false, - async transform(value, _, done) { - try { - buffer = Buffer.concat([buffer, value], buffer.length + value.length); - - while (true) { - // try to find the next instance of `toReplace` in buffer - const index = buffer.indexOf(toReplace); - - // if there is no next instance, break - if (index === -1) { - break; - } - - // flush everything to the left of the next instance - // of `toReplace` - this.push(buffer.slice(0, index)); - - // then flush an instance of `replacement` - this.push(replacement); - - // and finally update the buffer to include everything - // to the right of `toReplace`, dropping to replace from the buffer - buffer = buffer.slice(index + toReplace.length); - } - - // until now we have only flushed data that is to the left - // of a discovered instance of `toReplace`. If `toReplace` is - // never found this would lead to us buffering the entire stream. - // - // Instead, we only keep enough buffer to complete a potentially - // partial instance of `toReplace` - if (buffer.length > toReplace.length) { - // the entire buffer except the last `toReplace.length` bytes - // so that if all but one byte from `toReplace` is in the buffer, - // and the next chunk delivers the necessary byte, the buffer will then - // contain a complete `toReplace` token. - this.push(buffer.slice(0, buffer.length - toReplace.length)); - buffer = buffer.slice(-toReplace.length); - } - - done(); - } catch (err) { - done(err); - } - }, - - flush(callback) { - if (buffer.length) { - this.push(buffer); - } - - callback(); - }, - }); -} diff --git a/packages/kbn-es-archiver/src/lib/streams/split_stream.test.js b/packages/kbn-es-archiver/src/lib/streams/split_stream.test.js deleted file mode 100644 index e0736d220ba5..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/split_stream.test.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createSplitStream, createConcatStream, createPromiseFromStreams } from './'; - -async function split(stream, input) { - const concat = createConcatStream(); - concat.write([]); - stream.pipe(concat); - const output = createPromiseFromStreams([concat]); - - input.forEach((i) => { - stream.write(i); - }); - stream.end(); - - return await output; -} - -describe('splitStream', () => { - test('splits buffers, produces strings', async () => { - const output = await split(createSplitStream('&'), [Buffer.from('foo&bar')]); - expect(output).toEqual(['foo', 'bar']); - }); - - test('supports mixed input', async () => { - const output = await split(createSplitStream('&'), [Buffer.from('foo&b'), 'ar']); - expect(output).toEqual(['foo', 'bar']); - }); - - test('supports buffer split chunks', async () => { - const output = await split(createSplitStream(Buffer.from('&')), ['foo&b', 'ar']); - expect(output).toEqual(['foo', 'bar']); - }); - - test('splits provided values by a delimiter', async () => { - const output = await split(createSplitStream('&'), ['foo&b', 'ar']); - expect(output).toEqual(['foo', 'bar']); - }); - - test('handles multi-character delimiters', async () => { - const output = await split(createSplitStream('oo'), ['foo&b', 'ar']); - expect(output).toEqual(['f', '&bar']); - }); - - test('handles delimiters that span multiple chunks', async () => { - const output = await split(createSplitStream('ba'), ['foo&b', 'ar']); - expect(output).toEqual(['foo&', 'r']); - }); - - test('produces an empty chunk if the split char is at the end of the input', async () => { - const output = await split(createSplitStream('&bar'), ['foo&b', 'ar']); - expect(output).toEqual(['foo', '']); - }); -}); diff --git a/packages/kbn-es-archiver/src/lib/streams/split_stream.ts b/packages/kbn-es-archiver/src/lib/streams/split_stream.ts deleted file mode 100644 index 1c9b59449bd9..000000000000 --- a/packages/kbn-es-archiver/src/lib/streams/split_stream.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Transform } from 'stream'; - -/** - * Creates a Transform stream that consumes a stream of Buffers - * and produces a stream of strings (in object mode) by splitting - * the received bytes using the splitChunk. - * - * Ways this is behaves like String#split: - * - instances of splitChunk are removed from the input - * - splitChunk can be on any size - * - if there are no bytes found after the last splitChunk - * a final empty chunk is emitted - * - * Ways this deviates from String#split: - * - splitChunk cannot be a regexp - * - an empty string or Buffer will not produce a stream of individual - * bytes like `string.split('')` would - */ -export function createSplitStream(splitChunk: string) { - let unsplitBuffer = Buffer.alloc(0); - - return new Transform({ - writableObjectMode: false, - readableObjectMode: true, - transform(chunk, _, callback) { - try { - let i; - let toSplit = Buffer.concat([unsplitBuffer, chunk]); - while ((i = toSplit.indexOf(splitChunk)) !== -1) { - const slice = toSplit.slice(0, i); - toSplit = toSplit.slice(i + splitChunk.length); - this.push(slice.toString('utf8')); - } - - unsplitBuffer = toSplit; - callback(undefined); - } catch (err) { - callback(err); - } - }, - - flush(callback) { - try { - this.push(unsplitBuffer.toString('utf8')); - - callback(undefined); - } catch (err) { - callback(err); - } - }, - }); -} diff --git a/packages/kbn-legacy-logging/package.json b/packages/kbn-legacy-logging/package.json index 9311b3e2a77b..808fedc788d9 100644 --- a/packages/kbn-legacy-logging/package.json +++ b/packages/kbn-legacy-logging/package.json @@ -10,6 +10,6 @@ "kbn:watch": "yarn build --watch" }, "dependencies": { - "@kbn/std": "link:../kbn-std" + "@kbn/utils": "link:../kbn-utils" } } diff --git a/packages/kbn-legacy-logging/src/log_format_json.test.ts b/packages/kbn-legacy-logging/src/log_format_json.test.ts index f762daf01e5f..b31c45535e1a 100644 --- a/packages/kbn-legacy-logging/src/log_format_json.test.ts +++ b/packages/kbn-legacy-logging/src/log_format_json.test.ts @@ -20,7 +20,7 @@ import moment from 'moment'; import { attachMetaData } from './metadata'; -import { createListStream, createPromiseFromStreams } from './test_utils'; +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; import { KbnLoggerJsonFormat } from './log_format_json'; const time = +moment('2010-01-01T05:15:59Z', moment.ISO_8601); diff --git a/packages/kbn-legacy-logging/src/log_format_string.test.ts b/packages/kbn-legacy-logging/src/log_format_string.test.ts index 0ed233228c1f..d11a4a038d49 100644 --- a/packages/kbn-legacy-logging/src/log_format_string.test.ts +++ b/packages/kbn-legacy-logging/src/log_format_string.test.ts @@ -20,7 +20,7 @@ import moment from 'moment'; import { attachMetaData } from './metadata'; -import { createListStream, createPromiseFromStreams } from './test_utils'; +import { createListStream, createPromiseFromStreams } from '@kbn/utils'; import { KbnLoggerStringFormat } from './log_format_string'; const time = +moment('2010-01-01T05:15:59Z', moment.ISO_8601); diff --git a/packages/kbn-legacy-logging/src/test_utils/index.ts b/packages/kbn-legacy-logging/src/test_utils/index.ts deleted file mode 100644 index f13c869b563a..000000000000 --- a/packages/kbn-legacy-logging/src/test_utils/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { createListStream, createPromiseFromStreams } from './streams'; diff --git a/packages/kbn-legacy-logging/src/test_utils/streams.ts b/packages/kbn-legacy-logging/src/test_utils/streams.ts deleted file mode 100644 index 0f37a13f8a47..000000000000 --- a/packages/kbn-legacy-logging/src/test_utils/streams.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { pipeline, Writable, Readable } from 'stream'; - -/** - * Create a Readable stream that provides the items - * from a list as objects to subscribers - * - * @param {Array} items - the list of items to provide - * @return {Readable} - */ -export function createListStream(items: T | T[] = []) { - const queue = Array.isArray(items) ? [...items] : [items]; - - return new Readable({ - objectMode: true, - read(size) { - queue.splice(0, size).forEach((item) => { - this.push(item); - }); - - if (!queue.length) { - this.push(null); - } - }, - }); -} - -/** - * Take an array of streams, pipe the output - * from each one into the next, listening for - * errors from any of the streams, and then resolve - * the promise once the final stream has finished - * writing/reading. - * - * If the last stream is readable, it's final value - * will be provided as the promise value. - * - * Errors emitted from any stream will cause - * the promise to be rejected with that error. - * - * @param {Array} streams - * @return {Promise} - */ - -function isReadable(stream: Readable | Writable): stream is Readable { - return 'read' in stream && typeof stream.read === 'function'; -} - -export async function createPromiseFromStreams(streams: [Readable, ...Writable[]]): Promise { - let finalChunk: any; - const last = streams[streams.length - 1]; - if (!isReadable(last) && streams.length === 1) { - // For a nicer error than what stream.pipeline throws - throw new Error('A minimum of 2 streams is required when a non-readable stream is given'); - } - if (isReadable(last)) { - // We are pushing a writable stream to capture the last chunk - streams.push( - new Writable({ - // Use object mode even when "last" stream isn't. This allows to - // capture the last chunk as-is. - objectMode: true, - write(chunk, enc, done) { - finalChunk = chunk; - done(); - }, - }) - ); - } - - return new Promise((resolve, reject) => { - // @ts-expect-error 'pipeline' doesn't support variable length of arguments - pipeline(...streams, (err) => { - if (err) return reject(err); - resolve(finalChunk); - }); - }); -} diff --git a/packages/kbn-utils/src/index.ts b/packages/kbn-utils/src/index.ts index 7a894d72d562..30362112140a 100644 --- a/packages/kbn-utils/src/index.ts +++ b/packages/kbn-utils/src/index.ts @@ -20,3 +20,4 @@ export * from './package_json'; export * from './path'; export * from './repo_root'; +export * from './streams'; diff --git a/src/core/server/utils/streams/concat_stream.test.ts b/packages/kbn-utils/src/streams/concat_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/concat_stream.test.ts rename to packages/kbn-utils/src/streams/concat_stream.test.ts diff --git a/src/core/server/utils/streams/concat_stream.ts b/packages/kbn-utils/src/streams/concat_stream.ts similarity index 100% rename from src/core/server/utils/streams/concat_stream.ts rename to packages/kbn-utils/src/streams/concat_stream.ts diff --git a/src/core/server/utils/streams/concat_stream_providers.test.ts b/packages/kbn-utils/src/streams/concat_stream_providers.test.ts similarity index 100% rename from src/core/server/utils/streams/concat_stream_providers.test.ts rename to packages/kbn-utils/src/streams/concat_stream_providers.test.ts diff --git a/src/core/server/utils/streams/concat_stream_providers.ts b/packages/kbn-utils/src/streams/concat_stream_providers.ts similarity index 100% rename from src/core/server/utils/streams/concat_stream_providers.ts rename to packages/kbn-utils/src/streams/concat_stream_providers.ts diff --git a/src/core/server/utils/streams/filter_stream.test.ts b/packages/kbn-utils/src/streams/filter_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/filter_stream.test.ts rename to packages/kbn-utils/src/streams/filter_stream.test.ts diff --git a/src/core/server/utils/streams/filter_stream.ts b/packages/kbn-utils/src/streams/filter_stream.ts similarity index 100% rename from src/core/server/utils/streams/filter_stream.ts rename to packages/kbn-utils/src/streams/filter_stream.ts diff --git a/packages/kbn-es-archiver/src/lib/streams/index.ts b/packages/kbn-utils/src/streams/index.ts similarity index 100% rename from packages/kbn-es-archiver/src/lib/streams/index.ts rename to packages/kbn-utils/src/streams/index.ts diff --git a/src/core/server/utils/streams/intersperse_stream.test.ts b/packages/kbn-utils/src/streams/intersperse_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/intersperse_stream.test.ts rename to packages/kbn-utils/src/streams/intersperse_stream.test.ts diff --git a/src/core/server/utils/streams/intersperse_stream.ts b/packages/kbn-utils/src/streams/intersperse_stream.ts similarity index 100% rename from src/core/server/utils/streams/intersperse_stream.ts rename to packages/kbn-utils/src/streams/intersperse_stream.ts diff --git a/src/core/server/utils/streams/list_stream.test.ts b/packages/kbn-utils/src/streams/list_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/list_stream.test.ts rename to packages/kbn-utils/src/streams/list_stream.test.ts diff --git a/src/core/server/utils/streams/list_stream.ts b/packages/kbn-utils/src/streams/list_stream.ts similarity index 100% rename from src/core/server/utils/streams/list_stream.ts rename to packages/kbn-utils/src/streams/list_stream.ts diff --git a/src/core/server/utils/streams/map_stream.test.ts b/packages/kbn-utils/src/streams/map_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/map_stream.test.ts rename to packages/kbn-utils/src/streams/map_stream.test.ts diff --git a/src/core/server/utils/streams/map_stream.ts b/packages/kbn-utils/src/streams/map_stream.ts similarity index 100% rename from src/core/server/utils/streams/map_stream.ts rename to packages/kbn-utils/src/streams/map_stream.ts diff --git a/src/core/server/utils/streams/promise_from_streams.test.ts b/packages/kbn-utils/src/streams/promise_from_streams.test.ts similarity index 100% rename from src/core/server/utils/streams/promise_from_streams.test.ts rename to packages/kbn-utils/src/streams/promise_from_streams.test.ts diff --git a/src/core/server/utils/streams/promise_from_streams.ts b/packages/kbn-utils/src/streams/promise_from_streams.ts similarity index 100% rename from src/core/server/utils/streams/promise_from_streams.ts rename to packages/kbn-utils/src/streams/promise_from_streams.ts diff --git a/src/core/server/utils/streams/reduce_stream.test.ts b/packages/kbn-utils/src/streams/reduce_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/reduce_stream.test.ts rename to packages/kbn-utils/src/streams/reduce_stream.test.ts diff --git a/src/core/server/utils/streams/reduce_stream.ts b/packages/kbn-utils/src/streams/reduce_stream.ts similarity index 100% rename from src/core/server/utils/streams/reduce_stream.ts rename to packages/kbn-utils/src/streams/reduce_stream.ts diff --git a/src/core/server/utils/streams/replace_stream.test.ts b/packages/kbn-utils/src/streams/replace_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/replace_stream.test.ts rename to packages/kbn-utils/src/streams/replace_stream.test.ts diff --git a/src/core/server/utils/streams/replace_stream.ts b/packages/kbn-utils/src/streams/replace_stream.ts similarity index 100% rename from src/core/server/utils/streams/replace_stream.ts rename to packages/kbn-utils/src/streams/replace_stream.ts diff --git a/src/core/server/utils/streams/split_stream.test.ts b/packages/kbn-utils/src/streams/split_stream.test.ts similarity index 100% rename from src/core/server/utils/streams/split_stream.test.ts rename to packages/kbn-utils/src/streams/split_stream.test.ts diff --git a/src/core/server/utils/streams/split_stream.ts b/packages/kbn-utils/src/streams/split_stream.ts similarity index 100% rename from src/core/server/utils/streams/split_stream.ts rename to packages/kbn-utils/src/streams/split_stream.ts diff --git a/src/cli_keystore/add.js b/src/cli_keystore/add.js index d88256da1aa5..cec25b631f07 100644 --- a/src/cli_keystore/add.js +++ b/src/cli_keystore/add.js @@ -19,7 +19,8 @@ import { Logger } from '../cli_plugin/lib/logger'; import { confirm, question } from './utils'; -import { createPromiseFromStreams, createConcatStream } from '../core/server/utils'; +// import from path since add.test.js mocks 'fs' required for @kbn/utils +import { createPromiseFromStreams, createConcatStream } from '@kbn/utils/target/streams'; /** * @param {Keystore} keystore diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index cf826eb27625..35381f49543a 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -17,6 +17,5 @@ * under the License. */ -export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; diff --git a/src/core/public/utils/share_weak_replay.test.ts b/src/core/public/utils/share_weak_replay.test.ts deleted file mode 100644 index beac851aa689..000000000000 --- a/src/core/public/utils/share_weak_replay.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as Rx from 'rxjs'; -import { map, materialize, take, toArray } from 'rxjs/operators'; - -import { shareWeakReplay } from './share_weak_replay'; - -let completedCounts = 0; - -function counter({ async = true }: { async?: boolean } = {}) { - let subCounter = 0; - - function sendCount(subscriber: Rx.Subscriber) { - let notifCounter = 0; - const sub = ++subCounter; - - while (!subscriber.closed) { - subscriber.next(`${sub}:${++notifCounter}`); - } - - completedCounts += 1; - } - - return new Rx.Observable((subscriber) => { - if (!async) { - sendCount(subscriber); - return; - } - - const id = setTimeout(() => sendCount(subscriber)); - return () => clearTimeout(id); - }); -} - -async function record(observable: Rx.Observable) { - return observable - .pipe( - materialize(), - map((n) => (n.kind === 'N' ? `N:${n.value}` : n.kind === 'E' ? `E:${n.error.message}` : 'C')), - toArray() - ) - .toPromise(); -} - -afterEach(() => { - completedCounts = 0; -}); - -it('multicasts an observable to multiple children, unsubs once all children do, and resubscribes on next subscription', async () => { - const shared = counter().pipe(shareWeakReplay(1)); - - await expect(Promise.all([record(shared.pipe(take(1))), record(shared.pipe(take(2)))])).resolves - .toMatchInlineSnapshot(` -Array [ - Array [ - "N:1:1", - "C", - ], - Array [ - "N:1:1", - "N:1:2", - "C", - ], -] -`); - - await expect(Promise.all([record(shared.pipe(take(3))), record(shared.pipe(take(4)))])).resolves - .toMatchInlineSnapshot(` -Array [ - Array [ - "N:2:1", - "N:2:2", - "N:2:3", - "C", - ], - Array [ - "N:2:1", - "N:2:2", - "N:2:3", - "N:2:4", - "C", - ], -] -`); - - expect(completedCounts).toBe(2); -}); - -it('resubscribes if parent errors', async () => { - let errorCounter = 0; - const shared = counter().pipe( - map((v, i) => { - if (i === 3) { - throw new Error(`error ${++errorCounter}`); - } - return v; - }), - shareWeakReplay(2) - ); - - await expect(Promise.all([record(shared), record(shared)])).resolves.toMatchInlineSnapshot(` -Array [ - Array [ - "N:1:1", - "N:1:2", - "N:1:3", - "E:error 1", - ], - Array [ - "N:1:1", - "N:1:2", - "N:1:3", - "E:error 1", - ], -] -`); - - await expect(Promise.all([record(shared), record(shared)])).resolves.toMatchInlineSnapshot(` -Array [ - Array [ - "N:2:1", - "N:2:2", - "N:2:3", - "E:error 2", - ], - Array [ - "N:2:1", - "N:2:2", - "N:2:3", - "E:error 2", - ], -] -`); - - expect(completedCounts).toBe(2); -}); - -it('resubscribes if parent completes', async () => { - const shared = counter().pipe(take(4), shareWeakReplay(4)); - - await expect(Promise.all([record(shared.pipe(take(1))), record(shared)])).resolves - .toMatchInlineSnapshot(` -Array [ - Array [ - "N:1:1", - "C", - ], - Array [ - "N:1:1", - "N:1:2", - "N:1:3", - "N:1:4", - "C", - ], -] -`); - - await expect(Promise.all([record(shared.pipe(take(2))), record(shared)])).resolves - .toMatchInlineSnapshot(` -Array [ - Array [ - "N:2:1", - "N:2:2", - "C", - ], - Array [ - "N:2:1", - "N:2:2", - "N:2:3", - "N:2:4", - "C", - ], -] -`); - - expect(completedCounts).toBe(2); -}); - -it('supports parents that complete synchronously', async () => { - const next = jest.fn(); - const complete = jest.fn(); - const shared = counter({ async: false }).pipe(take(3), shareWeakReplay(1)); - - shared.subscribe({ next, complete }); - expect(next.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - "1:1", - ], - Array [ - "1:2", - ], - Array [ - "1:3", - ], -] -`); - expect(complete).toHaveBeenCalledTimes(1); - - next.mockClear(); - complete.mockClear(); - - shared.subscribe({ next, complete }); - expect(next.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - "2:1", - ], - Array [ - "2:2", - ], - Array [ - "2:3", - ], -] -`); - expect(complete).toHaveBeenCalledTimes(1); - - expect(completedCounts).toBe(2); -}); diff --git a/src/core/public/utils/share_weak_replay.ts b/src/core/public/utils/share_weak_replay.ts deleted file mode 100644 index 5ed6f76c5a05..000000000000 --- a/src/core/public/utils/share_weak_replay.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as Rx from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; - -/** - * Just like the [`shareReplay()`](https://rxjs-dev.firebaseapp.com/api/operators/shareReplay) operator from - * RxJS except for a few key differences: - * - * - If all downstream subscribers unsubscribe the source subscription will be unsubscribed. - * - * - Replay-ability is only maintained while the source is active, if it completes or errors - * then complete/error is sent to the current subscribers and the replay buffer is cleared. - * - * - Any subscription after the the source completes or errors will create a new subscription - * to the source observable. - * - * @param bufferSize Optional, default is `Number.POSITIVE_INFINITY` - */ -export function shareWeakReplay(bufferSize?: number): Rx.MonoTypeOperatorFunction { - return (source: Rx.Observable) => { - let subject: Rx.ReplaySubject | undefined; - const stop$ = new Rx.Subject(); - - return new Rx.Observable((observer) => { - if (!subject) { - subject = new Rx.ReplaySubject(bufferSize); - } - - subject.subscribe(observer).add(() => { - if (!subject) { - return; - } - - if (subject.observers.length === 0) { - stop$.next(); - } - - if (subject.closed || subject.isStopped) { - subject = undefined; - } - }); - - if (subject && subject.observers.length === 1) { - source.pipe(takeUntil(stop$)).subscribe(subject); - } - }); - }; -} diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index c26467f4b931..8f397c01ffa7 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -20,7 +20,7 @@ import { exportSavedObjectsToStream } from './get_sorted_objects_for_export'; import { savedObjectsClientMock } from '../service/saved_objects_client.mock'; import { Readable } from 'stream'; -import { createPromiseFromStreams, createConcatStream } from '../../utils/streams'; +import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; async function readStreamToCompletion(stream: Readable) { return createPromiseFromStreams([stream, createConcatStream([])]); diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index 7965b12eb874..84b14d0a5f02 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -18,7 +18,7 @@ */ import Boom from '@hapi/boom'; -import { createListStream } from '../../utils/streams'; +import { createListStream } from '@kbn/utils'; import { SavedObjectsClientContract, SavedObject, diff --git a/src/core/server/saved_objects/import/collect_saved_objects.ts b/src/core/server/saved_objects/import/collect_saved_objects.ts index 8e84f864cf44..8f09e69f6c72 100644 --- a/src/core/server/saved_objects/import/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/collect_saved_objects.ts @@ -23,7 +23,8 @@ import { createFilterStream, createMapStream, createPromiseFromStreams, -} from '../../utils/streams'; +} from '@kbn/utils'; + import { SavedObject } from '../types'; import { createLimitStream } from './create_limit_stream'; import { SavedObjectsImportError } from './types'; diff --git a/src/core/server/saved_objects/import/create_limit_stream.test.ts b/src/core/server/saved_objects/import/create_limit_stream.test.ts index a7e689710a56..0070a52fdd1c 100644 --- a/src/core/server/saved_objects/import/create_limit_stream.test.ts +++ b/src/core/server/saved_objects/import/create_limit_stream.test.ts @@ -17,11 +17,7 @@ * under the License. */ -import { - createConcatStream, - createListStream, - createPromiseFromStreams, -} from '../../utils/streams'; +import { createConcatStream, createListStream, createPromiseFromStreams } from '@kbn/utils'; import { createLimitStream } from './create_limit_stream'; describe('createLimitStream()', () => { diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 5b4fd57e1125..05a91f4aa4c2 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -19,7 +19,8 @@ import { schema } from '@kbn/config-schema'; import stringify from 'json-stable-stringify'; -import { createPromiseFromStreams, createMapStream, createConcatStream } from '../../utils/streams'; +import { createPromiseFromStreams, createMapStream, createConcatStream } from '@kbn/utils'; + import { IRouter } from '../../http'; import { SavedObjectConfig } from '../saved_objects_config'; import { exportSavedObjectsToStream } from '../export'; diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index d0fcd4b8b66d..07bf320c2949 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -22,9 +22,9 @@ jest.mock('../../export', () => ({ })); import * as exportMock from '../../export'; -import { createListStream } from '../../../utils/streams'; import supertest from 'supertest'; -import { UnwrapPromise } from '@kbn/utility-types'; +import type { UnwrapPromise } from '@kbn/utility-types'; +import { createListStream } from '@kbn/utils'; import { SavedObjectConfig } from '../../saved_objects_config'; import { registerExportRoute } from '../export'; import { setupServer, createExportableType } from '../test_utils'; diff --git a/src/core/server/saved_objects/routes/utils.test.ts b/src/core/server/saved_objects/routes/utils.test.ts index 693513dfc7c4..eaa9a42821e4 100644 --- a/src/core/server/saved_objects/routes/utils.test.ts +++ b/src/core/server/saved_objects/routes/utils.test.ts @@ -19,7 +19,7 @@ import { createSavedObjectsStreamFromNdJson, validateTypes, validateObjects } from './utils'; import { Readable } from 'stream'; -import { createPromiseFromStreams, createConcatStream } from '../../utils/streams'; +import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; async function readStreamToCompletion(stream: Readable) { return createPromiseFromStreams([stream, createConcatStream([])]); diff --git a/src/core/server/saved_objects/routes/utils.ts b/src/core/server/saved_objects/routes/utils.ts index 6536406d116d..83cb2ef75bd5 100644 --- a/src/core/server/saved_objects/routes/utils.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -26,7 +26,7 @@ import { createPromiseFromStreams, createListStream, createConcatStream, -} from '../../utils/streams'; +} from '@kbn/utils'; export async function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) { const savedObjects = await createPromiseFromStreams([ diff --git a/src/core/server/utils/index.ts b/src/core/server/utils/index.ts index d9c4217c4117..b01a4c4e0489 100644 --- a/src/core/server/utils/index.ts +++ b/src/core/server/utils/index.ts @@ -20,4 +20,3 @@ export * from './crypto'; export * from './from_root'; export * from './package_json'; -export * from './streams'; diff --git a/src/core/server/utils/streams/index.ts b/src/core/server/utils/streams/index.ts deleted file mode 100644 index 447d1ed5b1c5..000000000000 --- a/src/core/server/utils/streams/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { concatStreamProviders } from './concat_stream_providers'; -export { createIntersperseStream } from './intersperse_stream'; -export { createSplitStream } from './split_stream'; -export { createListStream } from './list_stream'; -export { createReduceStream } from './reduce_stream'; -export { createPromiseFromStreams } from './promise_from_streams'; -export { createConcatStream } from './concat_stream'; -export { createMapStream } from './map_stream'; -export { createReplaceStream } from './replace_stream'; -export { createFilterStream } from './filter_stream'; diff --git a/src/dev/build/lib/watch_stdio_for_line.ts b/src/dev/build/lib/watch_stdio_for_line.ts index c97b1c3b26db..38e0a93ae131 100644 --- a/src/dev/build/lib/watch_stdio_for_line.ts +++ b/src/dev/build/lib/watch_stdio_for_line.ts @@ -20,11 +20,7 @@ import { Transform } from 'stream'; import { ExecaChildProcess } from 'execa'; -import { - createPromiseFromStreams, - createSplitStream, - createMapStream, -} from '../../../core/server/utils'; +import { createPromiseFromStreams, createSplitStream, createMapStream } from '@kbn/utils'; // creates a stream that skips empty lines unless they are followed by // another line, preventing the empty lines produced by splitStream diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 688036c59c8f..adf027a430f8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -7,6 +7,7 @@ import { chunk } from 'lodash/fp'; import { extname } from 'path'; import { schema } from '@kbn/config-schema'; +import { createPromiseFromStreams } from '@kbn/utils'; import { validate } from '../../../../../common/validate'; import { @@ -20,7 +21,6 @@ import { } from '../../../../../common/detection_engine/schemas/response/import_rules_schema'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { IRouter } from '../../../../../../../../src/core/server'; -import { createPromiseFromStreams } from '../../../../../../../../src/core/server/utils/'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { ConfigType } from '../../../../config'; import { SetupPlugins } from '../../../../plugin'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts index 0bd6d43cab46..b3b8296d9047 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { Readable } from 'stream'; +import { createPromiseFromStreams } from '@kbn/utils'; + import { transformAlertToRule, getIdError, @@ -22,7 +24,6 @@ import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; import { PartialFilter, RuleTypeParams } from '../../types'; import { BulkError, ImportSuccessError } from '../utils'; import { getOutputRuleAlertForRest } from '../__mocks__/utils'; -import { createPromiseFromStreams } from '../../../../../../../../src/core/server/utils'; import { PartialAlert } from '../../../../../../alerts/server'; import { SanitizedAlert } from '../../../../../../alerts/server/types'; import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts index 60071bc2cef4..c6ec8f6df189 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { Readable } from 'stream'; +import { createPromiseFromStreams } from '@kbn/utils'; import { createRulesStreamFromNdJson } from './create_rules_stream_from_ndjson'; -import { createPromiseFromStreams } from 'src/core/server/utils'; import { BadRequestError } from '../errors/bad_request_error'; import { ImportRulesSchemaDecoded } from '../../../../common/detection_engine/schemas/request/import_rules_schema'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts index cd574a8d9561..b2c540388712 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts @@ -7,6 +7,8 @@ import { Transform } from 'stream'; import * as t from 'io-ts'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; +import { createSplitStream, createMapStream, createConcatStream } from '@kbn/utils'; + import { formatErrors } from '../../../../common/format_errors'; import { importRuleValidateTypeDependents } from '../../../../common/detection_engine/schemas/request/import_rules_type_dependents'; import { exactCheck } from '../../../../common/exact_check'; @@ -15,11 +17,6 @@ import { ImportRulesSchema, ImportRulesSchemaDecoded, } from '../../../../common/detection_engine/schemas/request/import_rules_schema'; -import { - createSplitStream, - createMapStream, - createConcatStream, -} from '../../../../../../../src/core/server/utils'; import { BadRequestError } from '../errors/bad_request_error'; import { parseNdjsonStrings, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/create_timelines_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/lib/timeline/create_timelines_stream_from_ndjson.ts index 2827cd373d5e..8e795895764c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/create_timelines_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/create_timelines_stream_from_ndjson.ts @@ -9,11 +9,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { failure } from 'io-ts/lib/PathReporter'; import { identity } from 'fp-ts/lib/function'; -import { - createConcatStream, - createSplitStream, - createMapStream, -} from '../../../../../../src/core/server/utils'; +import { createConcatStream, createSplitStream, createMapStream } from '@kbn/utils'; import { parseNdjsonStrings, filterExportedCounts, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts index 13cc71840ec9..8c8cfad1100d 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts @@ -79,7 +79,7 @@ describe('import timelines', () => { }; }); - jest.doMock('../../../../../../../src/core/server/utils', () => { + jest.doMock('@kbn/utils', () => { return { createPromiseFromStreams: jest.fn().mockReturnValue(mockParsedObjects), }; @@ -545,7 +545,7 @@ describe('import timeline templates', () => { }; }); - jest.doMock('../../../../../../../src/core/server/utils', () => { + jest.doMock('@kbn/utils', () => { return { createPromiseFromStreams: jest.fn().mockReturnValue(mockParsedTemplateTimelineObjects), }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts index 9a3dbf365e02..488da5025531 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts @@ -7,11 +7,9 @@ import { set } from '@elastic/safer-lodash-set/fp'; import readline from 'readline'; import fs from 'fs'; import { Readable } from 'stream'; +import { createListStream } from '@kbn/utils'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; - -import { createListStream } from '../../../../../../../../src/core/server/utils'; - import { SetupPlugins } from '../../../../plugin'; import { FrameworkRequest } from '../../../framework'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts index a19b18e7d89b..f2b85b3ca0b4 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts @@ -7,6 +7,7 @@ import { has, chunk, omit } from 'lodash/fp'; import { Readable } from 'stream'; import uuid from 'uuid'; +import { createPromiseFromStreams } from '@kbn/utils'; import { TimelineStatus, @@ -21,7 +22,6 @@ import { createBulkErrorObject, BulkError } from '../../../detection_engine/rout import { createTimelines } from './create_timelines'; import { FrameworkRequest } from '../../../framework'; import { createTimelinesStreamFromNdJson } from '../../create_timelines_stream_from_ndjson'; -import { createPromiseFromStreams } from '../../../../../../../../src/core/server/utils'; import { getTupleDuplicateErrorsAndUniqueTimeline } from './get_timelines_from_stream'; import { CompareTimelinesStatus } from './compare_timelines_status'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts index c63978a1f046..c344e68faa7b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts @@ -5,7 +5,7 @@ */ import { join, resolve } from 'path'; -import { createPromiseFromStreams } from '../../../../../../../../src/core/server/utils'; +import { createPromiseFromStreams } from '@kbn/utils'; import { SecurityPluginSetup } from '../../../../../../security/server'; import { FrameworkRequest } from '../../../framework'; diff --git a/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts index 4446e82f99de..59769bb43815 100644 --- a/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts @@ -8,6 +8,8 @@ import { has, isString } from 'lodash/fp'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; +import { createMapStream, createFilterStream } from '@kbn/utils'; + import { formatErrors } from '../../../common/format_errors'; import { importRuleValidateTypeDependents } from '../../../common/detection_engine/schemas/request/import_rules_type_dependents'; import { @@ -16,7 +18,6 @@ import { ImportRulesSchema, } from '../../../common/detection_engine/schemas/request/import_rules_schema'; import { exactCheck } from '../../../common/exact_check'; -import { createMapStream, createFilterStream } from '../../../../../../src/core/server/utils'; import { BadRequestError } from '../../lib/detection_engine/errors/bad_request_error'; export interface RulesObjectsExportResultDetails { diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_copy_to_space_mocks.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_copy_to_space_mocks.ts index ef6f5e1541a4..9345cad61c31 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_copy_to_space_mocks.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_copy_to_space_mocks.ts @@ -5,7 +5,7 @@ */ import { Readable } from 'stream'; -import { createPromiseFromStreams, createConcatStream } from 'src/core/server/utils'; +import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; async function readStreamToCompletion(stream: Readable) { return (await (createPromiseFromStreams([stream, createConcatStream([])]) as unknown)) as any[]; From bfbb43e59bbf31d1a926463c751182411cb26925 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 24 Nov 2020 15:29:32 +0100 Subject: [PATCH 20/89] [ML] Improve browser history navigation (#83792) * [ML] replace history support * [ML] explorer url state * [ML] timeseriesexplorer url state * [ML] fix state keys for mlSelectSeverity and mlSelectInterval * [ML] fix useSelectedCells * [ML] update urls and tests in security app * [ML] fix TS * [ML] fix apm unit tests * [ML] fix typo * [ML] remove state sync * [ML] fix initial zoom set * [ML] fix initial zoom set * [ML]: update with useMlHref * [ML] fix TS issue --- .../MachineLearningLinks/MLJobLink.test.tsx | 6 +- .../ml/common/types/ml_url_generator.ts | 13 +-- .../checkbox_showcharts.tsx | 44 +++++----- .../select_interval/select_interval.tsx | 22 ++--- .../select_severity/select_severity.tsx | 12 +-- .../date_picker_wrapper.tsx | 12 +-- .../contexts/kibana/use_navigate_to_path.ts | 13 +-- .../explorer/actions/load_explorer_data.ts | 2 + .../explorer/explorer_dashboard_service.ts | 6 +- .../explorer/hooks/use_explorer_url_state.ts | 13 +++ .../explorer/hooks/use_selected_cells.ts | 18 ++-- .../jobs_list_view/jobs_list_view.js | 18 +++- .../application/routing/routes/explorer.tsx | 87 ++++++++++--------- .../application/routing/routes/jobs_list.tsx | 2 +- .../routing/routes/timeseriesexplorer.tsx | 52 +++++++---- .../timeseries_chart/timeseries_chart.js | 12 +-- .../hooks/use_timeseriesexplorer_url_state.ts | 13 +++ .../timeseriesexplorer.d.ts | 4 +- .../ml/public/application/util/url_state.tsx | 44 +++++++--- .../anomaly_detection_urls_generator.ts | 10 +-- .../ml_url_generator/ml_url_generator.test.ts | 8 +- .../ml/public/ml_url_generator/use_ml_href.ts | 11 ++- .../ml/links/create_explorer_link.test.ts | 28 ------ .../ml/links/create_explorer_link.test.tsx | 45 ++++++++++ .../ml/links/create_explorer_link.tsx | 45 ++++++---- 25 files changed, 316 insertions(+), 224 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts create mode 100644 x-pack/plugins/ml/public/application/timeseriesexplorer/hooks/use_timeseriesexplorer_url_state.ts delete mode 100644 x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.tsx diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx index 7e18132e59cf..b13b1f89da35 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx @@ -22,7 +22,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:())"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(timeseriesexplorer:(mlTimeSeriesExplorer:()))"` ); }); it('should produce the correct URL with jobId, serviceName, and transactionType', async () => { @@ -41,7 +41,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request)))"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(timeseriesexplorer:(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request))))"` ); }); @@ -61,7 +61,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(apm-production-485b-high_mean_transaction_duration)),refreshInterval:(pause:!t,value:10000),time:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-java,transaction.type:request)))"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(apm-production-485b-high_mean_transaction_duration)),refreshInterval:(pause:!t,value:10000),time:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))&_a=(timeseriesexplorer:(mlTimeSeriesExplorer:(entities:(service.name:opbeans-java,transaction.type:request))))"` ); }); }); diff --git a/x-pack/plugins/ml/common/types/ml_url_generator.ts b/x-pack/plugins/ml/common/types/ml_url_generator.ts index b5a78ee746ef..1232c94d7dee 100644 --- a/x-pack/plugins/ml/common/types/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/types/ml_url_generator.ts @@ -66,7 +66,7 @@ export type AnomalyDetectionUrlState = MLPageState< >; export interface ExplorerAppState { mlExplorerSwimlane: { - selectedType?: string; + selectedType?: 'overall' | 'viewBy'; selectedLanes?: string[]; selectedTimes?: number[]; showTopFieldValues?: boolean; @@ -81,6 +81,7 @@ export interface ExplorerAppState { queryString?: string; }; query?: any; + mlShowCharts?: boolean; } export interface ExplorerGlobalState { ml: { jobIds: JobId[] }; @@ -124,21 +125,21 @@ export interface TimeSeriesExplorerGlobalState { } export interface TimeSeriesExplorerAppState { - zoom?: { - from?: string; - to?: string; - }; mlTimeSeriesExplorer?: { forecastId?: string; detectorIndex?: number; entities?: Record; + zoom?: { + from?: string; + to?: string; + }; functionDescription?: string; }; query?: any; } export interface TimeSeriesExplorerPageState - extends Pick, + extends Pick, Pick { jobIds?: JobId[]; timeRange?: TimeRange; diff --git a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx index 70538d4dc3a9..d0a3bd065269 100644 --- a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx @@ -4,41 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ -/* - * React component for a checkbox element to toggle charts display. - */ -import React, { FC } from 'react'; - -import { EuiCheckbox } from '@elastic/eui'; -// @ts-ignore -import makeId from '@elastic/eui/lib/components/form/form_row/make_id'; - +import React, { FC, useCallback, useMemo } from 'react'; +import { EuiCheckbox, htmlIdGenerator } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { useUrlState } from '../../../util/url_state'; +import { useExplorerUrlState } from '../../../explorer/hooks/use_explorer_url_state'; const SHOW_CHARTS_DEFAULT = true; -const SHOW_CHARTS_APP_STATE_NAME = 'mlShowCharts'; -export const useShowCharts = () => { - const [appState, setAppState] = useUrlState('_a'); +export const useShowCharts = (): [boolean, (v: boolean) => void] => { + const [explorerUrlState, setExplorerUrlState] = useExplorerUrlState(); + + const showCharts = explorerUrlState?.mlShowCharts ?? SHOW_CHARTS_DEFAULT; - return [ - appState?.mlShowCharts !== undefined ? appState?.mlShowCharts : SHOW_CHARTS_DEFAULT, - (d: boolean) => setAppState(SHOW_CHARTS_APP_STATE_NAME, d), - ]; + const setShowCharts = useCallback( + (v: boolean) => { + setExplorerUrlState({ mlShowCharts: v }); + }, + [setExplorerUrlState] + ); + + return [showCharts, setShowCharts]; }; +/* + * React component for a checkbox element to toggle charts display. + */ export const CheckboxShowCharts: FC = () => { - const [showCharts, setShowCarts] = useShowCharts(); + const [showCharts, setShowCharts] = useShowCharts(); const onChange = (e: React.ChangeEvent) => { - setShowCarts(e.target.checked); + setShowCharts(e.target.checked); }; + const id = useMemo(() => htmlIdGenerator()(), []); + return ( { - const [appState, setAppState] = useUrlState('_a'); - return [ - (appState && appState[TABLE_INTERVAL_APP_STATE_NAME]) || TABLE_INTERVAL_DEFAULT, - (d: TableInterval) => setAppState(TABLE_INTERVAL_APP_STATE_NAME, d), - ]; +export const useTableInterval = (): [TableInterval, (v: TableInterval) => void] => { + return usePageUrlState('mlSelectInterval', TABLE_INTERVAL_DEFAULT); }; +/* + * React component for rendering a select element with various aggregation interval levels. + */ export const SelectInterval: FC = () => { const [interval, setInterval] = useTableInterval(); diff --git a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx index b8333e72c9ff..3e48dcba84be 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx @@ -14,7 +14,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; import { getSeverityColor } from '../../../../../common/util/anomaly_utils'; -import { useUrlState } from '../../../util/url_state'; +import { usePageUrlState } from '../../../util/url_state'; const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', { defaultMessage: 'warning', @@ -78,15 +78,9 @@ function optionValueToThreshold(value: number) { } const TABLE_SEVERITY_DEFAULT = SEVERITY_OPTIONS[0]; -const TABLE_SEVERITY_APP_STATE_NAME = 'mlSelectSeverity'; -export const useTableSeverity = () => { - const [appState, setAppState] = useUrlState('_a'); - - return [ - (appState && appState[TABLE_SEVERITY_APP_STATE_NAME]) || TABLE_SEVERITY_DEFAULT, - (d: TableSeverity) => setAppState(TABLE_SEVERITY_APP_STATE_NAME, d), - ]; +export const useTableSeverity = (): [TableSeverity, (v: TableSeverity) => void] => { + return usePageUrlState('mlSelectSeverity', TABLE_SEVERITY_DEFAULT); }; const getSeverityOptions = () => diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx index 7d1a616d5711..a4dc78ea53a7 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useState, useEffect, useCallback } from 'react'; +import React, { FC, Fragment, useCallback, useEffect, useState } from 'react'; import { Subscription } from 'rxjs'; +import { debounce } from 'lodash'; + import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui'; -import { TimeRange, TimeHistoryContract } from 'src/plugins/data/public'; +import { TimeHistoryContract, TimeRange } from 'src/plugins/data/public'; import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service'; import { useUrlState } from '../../../util/url_state'; @@ -52,9 +54,9 @@ export const DatePickerWrapper: FC = () => { globalState?.refreshInterval ?? timefilter.getRefreshInterval(); const setRefreshInterval = useCallback( - (refreshIntervalUpdate: RefreshInterval) => { - setGlobalState('refreshInterval', refreshIntervalUpdate); - }, + debounce((refreshIntervalUpdate: RefreshInterval) => { + setGlobalState('refreshInterval', refreshIntervalUpdate, true); + }, 200), [setGlobalState] ); diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts b/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts index 96d41be03a14..6e9ac4d0a1e1 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/use_navigate_to_path.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useMemo } from 'react'; +import { useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { PLUGIN_ID } from '../../../../common/constants/app'; @@ -21,8 +21,8 @@ export const useNavigateToPath = () => { const location = useLocation(); - return useMemo(() => { - return (path: string | undefined, preserveSearch = false) => { + return useCallback( + async (path: string | undefined, preserveSearch = false) => { if (path === undefined) return; const modifiedPath = `${path}${preserveSearch === true ? location.search : ''}`; /** @@ -33,7 +33,8 @@ export const useNavigateToPath = () => { : getUrlForApp(PLUGIN_ID, { path: modifiedPath, }); - navigateToUrl(url); - }; - }, [location]); + await navigateToUrl(url); + }, + [location] + ); }; diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 77826d60b239..5712f3c4843b 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -285,6 +285,7 @@ export const useExplorerData = (): [Partial | undefined, (d: any) uiSettings, }, } = useMlKibana(); + const loadExplorerData = useMemo(() => { const service = new AnomalyTimelineService( timefilter, @@ -293,6 +294,7 @@ export const useExplorerData = (): [Partial | undefined, (d: any) ); return loadExplorerDataProvider(service); }, []); + const loadExplorerData$ = useMemo(() => new Subject(), []); const explorerData$ = useMemo(() => loadExplorerData$.pipe(switchMap(loadExplorerData)), []); const explorerData = useObservable(explorerData$); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index e0ed2ea6cf5e..8a95e5c6adbd 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -95,7 +95,9 @@ const setExplorerDataActionCreator = (payload: DeepPartial) => ({ type: EXPLORER_ACTION.SET_EXPLORER_DATA, payload, }); -const setFilterDataActionCreator = (payload: DeepPartial) => ({ +const setFilterDataActionCreator = ( + payload: Partial> +) => ({ type: EXPLORER_ACTION.SET_FILTER_DATA, payload, }); @@ -134,7 +136,7 @@ export const explorerService = { setExplorerData: (payload: DeepPartial) => { explorerAction$.next(setExplorerDataActionCreator(payload)); }, - setFilterData: (payload: DeepPartial) => { + setFilterData: (payload: Partial>) => { explorerAction$.next(setFilterDataActionCreator(payload)); }, setSwimlaneContainerWidth: (payload: number) => { diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts new file mode 100644 index 000000000000..d51be619c39e --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { usePageUrlState } from '../../util/url_state'; +import { ExplorerAppState } from '../../../../common/types/ml_url_generator'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; + +export function useExplorerUrlState() { + return usePageUrlState(ML_PAGES.ANOMALY_EXPLORER); +} diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts index c7cda2372bce..7602954b4c8c 100644 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts @@ -5,21 +5,21 @@ */ import { useCallback, useMemo } from 'react'; -import { useUrlState } from '../../util/url_state'; import { SWIMLANE_TYPE } from '../explorer_constants'; import { AppStateSelectedCells } from '../explorer_utils'; +import { ExplorerAppState } from '../../../../common/types/ml_url_generator'; export const useSelectedCells = ( - appState: any, - setAppState: ReturnType[1] + appState: ExplorerAppState, + setAppState: (update: Partial) => void ): [AppStateSelectedCells | undefined, (swimlaneSelectedCells: AppStateSelectedCells) => void] => { // keep swimlane selection, restore selectedCells from AppState const selectedCells = useMemo(() => { return appState?.mlExplorerSwimlane?.selectedType !== undefined ? { type: appState.mlExplorerSwimlane.selectedType, - lanes: appState.mlExplorerSwimlane.selectedLanes, - times: appState.mlExplorerSwimlane.selectedTimes, + lanes: appState.mlExplorerSwimlane.selectedLanes!, + times: appState.mlExplorerSwimlane.selectedTimes!, showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues, viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName, } @@ -29,7 +29,9 @@ export const useSelectedCells = ( const setSelectedCells = useCallback( (swimlaneSelectedCells: AppStateSelectedCells) => { - const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane }; + const mlExplorerSwimlane = { + ...appState.mlExplorerSwimlane, + } as ExplorerAppState['mlExplorerSwimlane']; if (swimlaneSelectedCells !== undefined) { swimlaneSelectedCells.showTopFieldValues = false; @@ -51,13 +53,13 @@ export const useSelectedCells = ( mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; - setAppState('mlExplorerSwimlane', mlExplorerSwimlane); + setAppState({ mlExplorerSwimlane }); } else { delete mlExplorerSwimlane.selectedType; delete mlExplorerSwimlane.selectedLanes; delete mlExplorerSwimlane.selectedTimes; delete mlExplorerSwimlane.showTopFieldValues; - setAppState('mlExplorerSwimlane', mlExplorerSwimlane); + setAppState({ mlExplorerSwimlane }); } }, [appState?.mlExplorerSwimlane, selectedCells, setAppState] diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 6e3b9031de65..3b980ce52fa6 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -68,6 +68,12 @@ export class JobsListView extends Component { // used to block timeouts for results polling // which can run after unmounting this._isMounted = false; + /** + * Indicates if the filters has been initialized by {@link JobFilterBar} component + * @type {boolean} + * @private + */ + this._isFiltersSet = false; } componentDidMount() { @@ -227,9 +233,15 @@ export class JobsListView extends Component { const filterClauses = (query && query.ast && query.ast.clauses) || []; const filteredJobsSummaryList = filterJobs(this.state.jobsSummaryList, filterClauses); - this.props.onJobsViewStateUpdate({ - queryText: query?.text, - }); + this.props.onJobsViewStateUpdate( + { + queryText: query?.text, + }, + // Replace the URL state on filters initialization + this._isFiltersSet === false + ); + + this._isFiltersSet = true; this.setState({ filteredJobsSummaryList, filterClauses }, () => { this.refreshSelectedJobs(); diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index b91a5bd4a1aa..83f876bcf7b5 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -22,7 +22,6 @@ import { useSelectedCells } from '../../explorer/hooks/use_selected_cells'; import { mlJobService } from '../../services/job_service'; import { ml } from '../../services/ml_api_service'; import { useExplorerData } from '../../explorer/actions'; -import { ExplorerAppState } from '../../../../common/types/ml_url_generator'; import { explorerService } from '../../explorer/explorer_dashboard_service'; import { getDateFormatTz } from '../../explorer/explorer_utils'; import { useJobSelection } from '../../components/job_selector/use_job_selection'; @@ -36,6 +35,7 @@ import { isViewBySwimLaneData } from '../../explorer/swimlane_container'; import { JOB_ID } from '../../../../common/constants/anomalies'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; import { AnnotationUpdatesService } from '../../services/annotations_service'; +import { useExplorerUrlState } from '../../explorer/hooks/use_explorer_url_state'; export const explorerRouteFactory = ( navigateToPath: NavigateToPath, @@ -77,7 +77,8 @@ interface ExplorerUrlStateManagerProps { } const ExplorerUrlStateManager: FC = ({ jobsWithTimeRange }) => { - const [appState, setAppState] = useUrlState('_a'); + const [explorerUrlState, setExplorerUrlState] = useExplorerUrlState(); + const [globalState, setGlobalState] = useUrlState('_g'); const [lastRefresh, setLastRefresh] = useState(0); const [stoppedPartitions, setStoppedPartitions] = useState(); @@ -86,6 +87,9 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const { jobIds } = useJobSelection(jobsWithTimeRange); + const explorerAppState = useObservable(explorerService.appState$); + const explorerState = useObservable(explorerService.state$); + const refresh = useRefresh(); useEffect(() => { @@ -155,73 +159,76 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } }, [JSON.stringify(jobIds)]); + /** + * TODO get rid of the intermediate state in explorerService. + * URL state should be the only source of truth for related props. + */ useEffect(() => { - const viewByFieldName = appState?.mlExplorerSwimlane?.viewByFieldName; - if (viewByFieldName !== undefined) { - explorerService.setViewBySwimlaneFieldName(viewByFieldName); - } - - const filterData = appState?.mlExplorerFilter; + const filterData = explorerUrlState?.mlExplorerFilter; if (filterData !== undefined) { explorerService.setFilterData(filterData); } - const viewByPerPage = (appState as ExplorerAppState)?.mlExplorerSwimlane?.viewByPerPage; - if (viewByPerPage) { + const { viewByFieldName, viewByFromPage, viewByPerPage } = + explorerUrlState?.mlExplorerSwimlane ?? {}; + + if (viewByFieldName !== undefined) { + explorerService.setViewBySwimlaneFieldName(viewByFieldName); + } + + if (viewByPerPage !== undefined) { explorerService.setViewByPerPage(viewByPerPage); } - const viewByFromPage = (appState as ExplorerAppState)?.mlExplorerSwimlane?.viewByFromPage; - if (viewByFromPage) { + if (viewByFromPage !== undefined) { explorerService.setViewByFromPage(viewByFromPage); } }, []); + /** Sync URL state with {@link explorerService} state */ + useEffect(() => { + const replaceState = explorerUrlState?.mlExplorerSwimlane?.viewByFieldName === undefined; + if (explorerAppState?.mlExplorerSwimlane?.viewByFieldName !== undefined) { + setExplorerUrlState(explorerAppState, replaceState); + } + }, [explorerAppState]); + const [explorerData, loadExplorerData] = useExplorerData(); + useEffect(() => { if (explorerData !== undefined && Object.keys(explorerData).length > 0) { explorerService.setExplorerData(explorerData); } }, [explorerData]); - const explorerAppState = useObservable(explorerService.appState$); - useEffect(() => { - if ( - explorerAppState !== undefined && - explorerAppState.mlExplorerSwimlane.viewByFieldName !== undefined - ) { - setAppState(explorerAppState); - } - }, [explorerAppState]); - - const explorerState = useObservable(explorerService.state$); const [showCharts] = useShowCharts(); const [tableInterval] = useTableInterval(); const [tableSeverity] = useTableSeverity(); - const [selectedCells, setSelectedCells] = useSelectedCells(appState, setAppState); + const [selectedCells, setSelectedCells] = useSelectedCells(explorerUrlState, setExplorerUrlState); useEffect(() => { explorerService.setSelectedCells(selectedCells); }, [JSON.stringify(selectedCells)]); const loadExplorerDataConfig = - (explorerState !== undefined && { - bounds: explorerState.bounds, - lastRefresh, - influencersFilterQuery: explorerState.influencersFilterQuery, - noInfluencersConfigured: explorerState.noInfluencersConfigured, - selectedCells, - selectedJobs: explorerState.selectedJobs, - swimlaneBucketInterval: explorerState.swimlaneBucketInterval, - tableInterval: tableInterval.val, - tableSeverity: tableSeverity.val, - viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName, - swimlaneContainerWidth: explorerState.swimlaneContainerWidth, - viewByPerPage: explorerState.viewByPerPage, - viewByFromPage: explorerState.viewByFromPage, - }) || - undefined; + explorerState !== undefined + ? { + bounds: explorerState.bounds, + lastRefresh, + influencersFilterQuery: explorerState.influencersFilterQuery, + noInfluencersConfigured: explorerState.noInfluencersConfigured, + selectedCells, + selectedJobs: explorerState.selectedJobs, + swimlaneBucketInterval: explorerState.swimlaneBucketInterval, + tableInterval: tableInterval.val, + tableSeverity: tableSeverity.val, + viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName, + swimlaneContainerWidth: explorerState.swimlaneContainerWidth, + viewByPerPage: explorerState.viewByPerPage, + viewByFromPage: explorerState.viewByFromPage, + } + : undefined; useEffect(() => { if (explorerState && explorerState.swimlaneContainerWidth > 0) { diff --git a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx index d91ec27d9a50..6e14fc345e97 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx @@ -56,7 +56,7 @@ const PageWrapper: FC = ({ deps }) => { refreshValue === 0 && refreshPause === true ? { pause: false, value: DEFAULT_REFRESH_INTERVAL_MS } : { pause: refreshPause, value: refreshValue }; - setGlobalState({ refreshInterval }); + setGlobalState({ refreshInterval }, undefined, true); timefilter.setRefreshInterval(refreshInterval); }, []); const annotationUpdatesService = useMemo(() => new AnnotationUpdatesService(), []); diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index f0fb4558bcfa..df92c7725256 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -41,6 +41,9 @@ import { useTimefilter } from '../../contexts/kibana'; import { useToastNotificationService } from '../../services/toast_notification_service'; import { AnnotationUpdatesService } from '../../services/annotations_service'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; +import { useTimeSeriesExplorerUrlState } from '../../timeseriesexplorer/hooks/use_timeseriesexplorer_url_state'; +import { TimeSeriesExplorerAppState } from '../../../../common/types/ml_url_generator'; + export const timeSeriesExplorerRouteFactory = ( navigateToPath: NavigateToPath, basePath: string @@ -79,10 +82,7 @@ const PageWrapper: FC = ({ deps }) => { ); }; -interface AppStateZoom { - from: string; - to: string; -} +type AppStateZoom = Exclude['zoom']; interface TimeSeriesExplorerUrlStateManager { config: any; @@ -94,7 +94,10 @@ export const TimeSeriesExplorerUrlStateManager: FC { const toastNotificationService = useToastNotificationService(); - const [appState, setAppState] = useUrlState('_a'); + const [ + timeSeriesExplorerUrlState, + setTimeSeriesExplorerUrlState, + ] = useTimeSeriesExplorerUrlState(); const [globalState, setGlobalState] = useUrlState('_g'); const [lastRefresh, setLastRefresh] = useState(0); const previousRefresh = usePrevious(lastRefresh); @@ -148,7 +151,7 @@ export const TimeSeriesExplorerUrlStateManager: FC { - const mlTimeSeriesExplorer = - appState?.mlTimeSeriesExplorer !== undefined ? { ...appState.mlTimeSeriesExplorer } : {}; + /** + * Empty zoom indicates that chart hasn't been rendered yet, + * hence any updates prior that should replace the URL state. + */ + const isInitUpdate = timeSeriesExplorerUrlState?.mlTimeSeriesExplorer?.zoom === undefined; + + const mlTimeSeriesExplorer: TimeSeriesExplorerAppState['mlTimeSeriesExplorer'] = + timeSeriesExplorerUrlState?.mlTimeSeriesExplorer !== undefined + ? { ...timeSeriesExplorerUrlState.mlTimeSeriesExplorer } + : {}; switch (action) { case APP_STATE_ACTION.CLEAR: @@ -222,9 +235,12 @@ export const TimeSeriesExplorerUrlStateManager: FC( - appState?.mlTimeSeriesExplorer?.forecastId + timeSeriesExplorerUrlState?.mlTimeSeriesExplorer?.forecastId ); useEffect(() => { diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 20edd07556c0..32fa27b70989 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -395,13 +395,7 @@ class TimeseriesChartIntl extends Component { contextChartInitialized = false; drawContextChartSelection() { - const { - contextChartData, - contextChartSelected, - contextForecastData, - zoomFrom, - zoomTo, - } = this.props; + const { contextChartData, contextForecastData, zoomFrom, zoomTo } = this.props; if (contextChartData === undefined) { return; @@ -455,10 +449,6 @@ class TimeseriesChartIntl extends Component { new Date(contextXScaleDomain[0]), new Date(contextXScaleDomain[1]) ); - if (this.contextChartInitialized === false) { - this.contextChartInitialized = true; - contextChartSelected({ from: contextXScaleDomain[0], to: contextXScaleDomain[1] }); - } } } } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/hooks/use_timeseriesexplorer_url_state.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/hooks/use_timeseriesexplorer_url_state.ts new file mode 100644 index 000000000000..7faa6e2b8351 --- /dev/null +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/hooks/use_timeseriesexplorer_url_state.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { usePageUrlState } from '../../util/url_state'; +import { TimeSeriesExplorerAppState } from '../../../../common/types/ml_url_generator'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; + +export function useTimeSeriesExplorerUrlState() { + return usePageUrlState(ML_PAGES.SINGLE_METRIC_VIEWER); +} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts index 530ba567ed9f..8159dbb8ade0 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts @@ -16,9 +16,9 @@ declare const TimeSeriesExplorer: FC<{ lastRefresh: number; selectedJobId: string; selectedDetectorIndex: number; - selectedEntities: any[]; + selectedEntities: Record | undefined; selectedForecastId?: string; tableInterval: string; tableSeverity: number; - zoom?: { from: string; to: string }; + zoom?: { from?: string; to?: string }; }>; diff --git a/x-pack/plugins/ml/public/application/util/url_state.tsx b/x-pack/plugins/ml/public/application/util/url_state.tsx index 448a888ab32c..fdc6dd135cd6 100644 --- a/x-pack/plugins/ml/public/application/util/url_state.tsx +++ b/x-pack/plugins/ml/public/application/util/url_state.tsx @@ -19,7 +19,8 @@ type Accessor = '_a' | '_g'; export type SetUrlState = ( accessor: Accessor, attribute: string | Dictionary, - value?: any + value?: any, + replaceState?: boolean ) => void; export interface UrlState { searchString: string; @@ -78,7 +79,12 @@ export const UrlStateProvider: FC = ({ children }) => { const { search: searchString } = useLocation(); const setUrlState: SetUrlState = useCallback( - (accessor: Accessor, attribute: string | Dictionary, value?: any) => { + ( + accessor: Accessor, + attribute: string | Dictionary, + value?: any, + replaceState?: boolean + ) => { const prevSearchString = searchString; const urlState = parseUrlState(prevSearchString); const parsedQueryString = parse(prevSearchString, { sort: false }); @@ -120,7 +126,11 @@ export const UrlStateProvider: FC = ({ children }) => { if (oldLocationSearchString !== newLocationSearchString) { const newSearchString = stringify(parsedQueryString, { sort: false }); - history.push({ search: newSearchString }); + if (replaceState) { + history.replace({ search: newSearchString }); + } else { + history.push({ search: newSearchString }); + } } } catch (error) { // eslint-disable-next-line no-console @@ -144,37 +154,43 @@ export const useUrlState = (accessor: Accessor) => { }, [searchString]); const setUrlState = useCallback( - (attribute: string | Dictionary, value?: any) => { - setUrlStateContext(accessor, attribute, value); + (attribute: string | Dictionary, value?: any, replaceState?: boolean) => { + setUrlStateContext(accessor, attribute, value, replaceState); }, [accessor, setUrlStateContext] ); return [urlState, setUrlState]; }; +type AppStateKey = 'mlSelectSeverity' | 'mlSelectInterval' | MlPages; + /** * Hook for managing the URL state of the page. */ export const usePageUrlState = ( - pageKey: MlPages, - defaultState: PageUrlState -): [PageUrlState, (update: Partial) => void] => { + pageKey: AppStateKey, + defaultState?: PageUrlState +): [PageUrlState, (update: Partial, replaceState?: boolean) => void] => { const [appState, setAppState] = useUrlState('_a'); const pageState = appState?.[pageKey]; const resultPageState: PageUrlState = useMemo(() => { return { - ...defaultState, + ...(defaultState ?? {}), ...(pageState ?? {}), }; }, [pageState]); const onStateUpdate = useCallback( - (update: Partial, replace?: boolean) => { - setAppState(pageKey, { - ...(replace ? {} : resultPageState), - ...update, - }); + (update: Partial, replaceState?: boolean) => { + setAppState( + pageKey, + { + ...resultPageState, + ...update, + }, + replaceState + ); }, [pageKey, resultPageState, setAppState] ); diff --git a/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts index d53dfa8fd19c..d2814bd63b0b 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts @@ -132,9 +132,9 @@ export function createExplorerUrl( { useHash: false, storeInHashQuery: false }, url ); - url = setStateToKbnUrl>( + url = setStateToKbnUrl>>( '_a', - appState, + { [ML_PAGES.ANOMALY_EXPLORER]: appState }, { useHash: false, storeInHashQuery: false }, url ); @@ -157,7 +157,6 @@ export function createSingleMetricViewerUrl( timeRange, jobIds, refreshInterval, - zoom, query, detectorIndex, forecastId, @@ -196,7 +195,6 @@ export function createSingleMetricViewerUrl( appState.mlTimeSeriesExplorer = mlTimeSeriesExplorer; - if (zoom) appState.zoom = zoom; if (query) appState.query = { query_string: query, @@ -207,9 +205,9 @@ export function createSingleMetricViewerUrl( { useHash: false, storeInHashQuery: false }, url ); - url = setStateToKbnUrl( + url = setStateToKbnUrl>>( '_a', - appState, + { [ML_PAGES.SINGLE_METRIC_VIEWER]: appState }, { useHash: false, storeInHashQuery: false }, url ); diff --git a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts index 3f3d88f1a31d..7dcd901c2c0e 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts @@ -87,7 +87,7 @@ describe('MlUrlGenerator', () => { }, }); expect(url).toBe( - "/app/ml/explorer?_g=(ml:(jobIds:!(fq_single_1)),refreshInterval:(pause:!f,value:0),time:(from:'2019-02-07T00:00:00.000Z',mode:absolute,to:'2020-08-13T17:15:00.000Z'))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFromPage:2,viewByPerPage:20),query:(analyze_wildcard:!t,query:'*'))" + "/app/ml/explorer?_g=(ml:(jobIds:!(fq_single_1)),refreshInterval:(pause:!f,value:0),time:(from:'2019-02-07T00:00:00.000Z',mode:absolute,to:'2020-08-13T17:15:00.000Z'))&_a=(explorer:(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFromPage:2,viewByPerPage:20),query:(analyze_wildcard:!t,query:'*')))" ); }); it('should generate valid URL for the Anomaly Explorer page for multiple jobIds', async () => { @@ -103,7 +103,7 @@ describe('MlUrlGenerator', () => { }, }); expect(url).toBe( - "/app/ml/explorer?_g=(ml:(jobIds:!(fq_single_1,logs_categorization_1)),time:(from:'2019-02-07T00:00:00.000Z',mode:absolute,to:'2020-08-13T17:15:00.000Z'))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:())" + "/app/ml/explorer?_g=(ml:(jobIds:!(fq_single_1,logs_categorization_1)),time:(from:'2019-02-07T00:00:00.000Z',mode:absolute,to:'2020-08-13T17:15:00.000Z'))&_a=(explorer:(mlExplorerFilter:(),mlExplorerSwimlane:()))" ); }); }); @@ -130,7 +130,7 @@ describe('MlUrlGenerator', () => { }, }); expect(url).toBe( - "/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(logs_categorization_1)),refreshInterval:(pause:!f,value:0),time:(from:'2020-07-12T00:39:02.912Z',mode:absolute,to:'2020-07-22T15:52:18.613Z'))&_a=(mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))" + "/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(logs_categorization_1)),refreshInterval:(pause:!f,value:0),time:(from:'2020-07-12T00:39:02.912Z',mode:absolute,to:'2020-07-22T15:52:18.613Z'))&_a=(timeseriesexplorer:(mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*'))))" ); }); @@ -161,7 +161,7 @@ describe('MlUrlGenerator', () => { }, }); expect(url).toBe( - "/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(logs_categorization_1)),refreshInterval:(pause:!f,value:0),time:(from:'2020-07-12T00:39:02.912Z',mode:absolute,to:'2020-07-22T15:52:18.613Z'))&_a=(mlTimeSeriesExplorer:(detectorIndex:0,entities:(mlcategory:'2')),query:(query_string:(analyze_wildcard:!t,query:'*')),zoom:(from:'2020-07-20T23:58:29.367Z',to:'2020-07-21T11:00:13.173Z'))" + "/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(logs_categorization_1)),refreshInterval:(pause:!f,value:0),time:(from:'2020-07-12T00:39:02.912Z',mode:absolute,to:'2020-07-22T15:52:18.613Z'))&_a=(timeseriesexplorer:(mlTimeSeriesExplorer:(detectorIndex:0,entities:(mlcategory:'2')),query:(query_string:(analyze_wildcard:!t,query:'*'))))" ); }); }); diff --git a/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts b/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts index 8e5a6ef64e59..ad4aabd17f65 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts @@ -7,12 +7,19 @@ import { useEffect, useState } from 'react'; import { MlPluginStart } from '../index'; import { MlUrlGeneratorState } from '../../common/types/ml_url_generator'; + +/** + * Provides a URL to ML plugin page + * TODO remove basePath parameter + */ export const useMlHref = ( ml: MlPluginStart | undefined, - basePath: string, + basePath: string | undefined, params: MlUrlGeneratorState ) => { - const [mlLink, setMlLink] = useState(`${basePath}/app/ml/${params.page}`); + const [mlLink, setMlLink] = useState( + basePath !== undefined ? `${basePath}/app/ml/${params.page}` : undefined + ); useEffect(() => { let isCancelled = false; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.ts b/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.ts deleted file mode 100644 index 30d0673192af..000000000000 --- a/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mockAnomalies } from '../mock'; -import { cloneDeep } from 'lodash/fp'; -import { createExplorerLink } from './create_explorer_link'; - -describe('create_explorer_link', () => { - let anomalies = cloneDeep(mockAnomalies); - - beforeEach(() => { - anomalies = cloneDeep(mockAnomalies); - }); - - test('it returns expected link', () => { - const entities = createExplorerLink( - anomalies.anomalies[0], - new Date('1970').toISOString(), - new Date('3000').toISOString() - ); - expect(entities).toEqual( - "#/explorer?_g=(ml:(jobIds:!(job-1)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'1970-01-01T00:00:00.000Z',mode:absolute,to:'3000-01-01T00:00:00.000Z'))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(),mlSelectLimit:(display:'10',val:10),mlShowCharts:!t)" - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.tsx new file mode 100644 index 000000000000..d30cbd4b0e7f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 { render, act } from '@testing-library/react'; +import { mockAnomalies } from '../mock'; +import { cloneDeep } from 'lodash/fp'; +import { ExplorerLink } from './create_explorer_link'; +import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public/context'; +import { MlUrlGenerator } from '../../../../../../ml/public/ml_url_generator'; + +describe('create_explorer_link', () => { + let anomalies = cloneDeep(mockAnomalies); + + beforeEach(() => { + anomalies = cloneDeep(mockAnomalies); + }); + + test('it returns expected link', async () => { + const ml = { urlGenerator: new MlUrlGenerator({ appBasePath: '/app/ml', useHash: false }) }; + const http = { basePath: { get: jest.fn(() => {}) } }; + + await act(async () => { + const { findByText } = render( + + + + ); + + const url = (await findByText('Open in Anomaly Explorer')).getAttribute('href'); + + expect(url).toEqual( + "/app/ml/explorer?_g=(ml:(jobIds:!(job-1)),refreshInterval:(display:Off,pause:!t,value:0),time:(from:'1970-01-01T00:00:00.000Z',mode:absolute,to:'3000-01-01T00:00:00.000Z'))&_a=(explorer:(mlExplorerFilter:(),mlExplorerSwimlane:()))" + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.tsx b/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.tsx index 468bc962453f..2ba47cfccb1a 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/links/create_explorer_link.tsx @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiLink } from '@elastic/eui'; import React from 'react'; +import { EuiLink } from '@elastic/eui'; import { Anomaly } from '../types'; import { useKibana } from '../../../lib/kibana'; +import { useMlHref } from '../../../../../../ml/public'; interface ExplorerLinkProps { score: Anomaly; @@ -22,26 +23,32 @@ export const ExplorerLink: React.FC = ({ endDate, linkName, }) => { - const { getUrlForApp } = useKibana().services.application; + const { + services: { ml, http }, + } = useKibana(); + + const explorerUrl = useMlHref(ml, http.basePath.get(), { + page: 'explorer', + pageState: { + jobIds: [score.jobId], + timeRange: { + from: new Date(startDate).toISOString(), + to: new Date(endDate).toISOString(), + mode: 'absolute', + }, + refreshInterval: { + pause: true, + value: 0, + display: 'Off', + }, + }, + }); + + if (!explorerUrl) return null; + return ( - + {linkName} ); }; - -export const createExplorerLink = (score: Anomaly, startDate: string, endDate: string): string => { - const startDateIso = new Date(startDate).toISOString(); - const endDateIso = new Date(endDate).toISOString(); - - const JOB_PREFIX = `#/explorer?_g=(ml:(jobIds:!(${score.jobId}))`; - const REFRESH_INTERVAL = `,refreshInterval:(display:Off,pause:!f,value:0),time:(from:'${startDateIso}',mode:absolute,to:'${endDateIso}'))`; - const INTERVAL_SELECTION = `&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(),mlSelectLimit:(display:'10',val:10),mlShowCharts:!t)`; - - return `${JOB_PREFIX}${REFRESH_INTERVAL}${INTERVAL_SELECTION}`; -}; From 9521ac4fed87183498e9c8b2208a6eefa14f5d28 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Tue, 24 Nov 2020 09:43:21 -0500 Subject: [PATCH 21/89] [Fleet] Integration Policies List view (#83634) * Show table with list of Integration polices * hook to support list pagination that uses/persist to the URL --- .../applications/fleet/components/index.ts | 1 + .../fleet/components/link_and_revision.tsx | 45 +++++ .../public/applications/fleet/hooks/index.ts | 3 +- .../fleet/hooks/use_pagination.tsx | 15 +- .../fleet/hooks/use_url_pagination.ts | 94 +++++++++ .../step_select_agent_policy.tsx | 4 +- .../sections/agent_policy/list_page/index.tsx | 29 +-- .../sections/epm/screens/detail/content.tsx | 15 +- .../sections/epm/screens/detail/index.tsx | 3 +- .../sections/epm/screens/detail/layout.tsx | 26 ++- .../screens/detail/package_policies_panel.tsx | 186 +++++++++++++++++- .../sections/epm/screens/detail/persona.tsx | 47 +++++ .../use_package_policies_with_agent_policy.ts | 129 ++++++++++++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 15 files changed, 551 insertions(+), 48 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/persona.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/use_package_policies_with_agent_policy.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/index.ts b/x-pack/plugins/fleet/public/applications/fleet/components/index.ts index 93bc0645c7ee..ea6abc4bba5f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/index.ts @@ -11,3 +11,4 @@ export { PackageIcon } from './package_icon'; export { ContextMenuActions } from './context_menu_actions'; export { SearchBar } from './search_bar'; export * from './settings_flyout'; +export * from './link_and_revision'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx new file mode 100644 index 000000000000..a9e44b200cf6 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/components/link_and_revision.tsx @@ -0,0 +1,45 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { CSSProperties, memo } from 'react'; +import { EuiLinkProps } from '@elastic/eui/src/components/link/link'; + +const MIN_WIDTH: CSSProperties = { minWidth: 0 }; +const NO_WRAP_WHITE_SPACE: CSSProperties = { whiteSpace: 'nowrap' }; + +export type LinkAndRevisionProps = EuiLinkProps & { + revision?: string | number; +}; + +/** + * Components shows a link for a given value along with a revision number to its right. The display + * value is truncated if it is longer than the width of where it is displayed, while the revision + * always remain visible + */ +export const LinkAndRevision = memo( + ({ revision, className, ...euiLinkProps }) => { + return ( + + + + + {revision && ( + + + + + + )} + + ); + } +); diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts index 6026a5579f65..5b0243f12733 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/index.ts @@ -13,7 +13,8 @@ export { useBreadcrumbs } from './use_breadcrumbs'; export { useLink } from './use_link'; export { useKibanaLink } from './use_kibana_link'; export { usePackageIconType, UsePackageIconType } from './use_package_icon_type'; -export { usePagination, Pagination } from './use_pagination'; +export { usePagination, Pagination, PAGE_SIZE_OPTIONS } from './use_pagination'; +export { useUrlPagination } from './use_url_pagination'; export { useSorting } from './use_sorting'; export { useDebounce } from './use_debounce'; export * from './use_request'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_pagination.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_pagination.tsx index 699bba3c62f9..1fdd223ef804 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_pagination.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_pagination.tsx @@ -4,22 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; + +export const PAGE_SIZE_OPTIONS: readonly number[] = [5, 20, 50]; export interface Pagination { currentPage: number; pageSize: number; } -export function usePagination() { - const [pagination, setPagination] = useState({ +export function usePagination( + pageInfo: Pagination = { currentPage: 1, pageSize: 20, - }); + } +) { + const [pagination, setPagination] = useState(pageInfo); + const pageSizeOptions = useMemo(() => [...PAGE_SIZE_OPTIONS], []); return { pagination, setPagination, - pageSizeOptions: [5, 20, 50], + pageSizeOptions, }; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts new file mode 100644 index 000000000000..f9c351899fe0 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_url_pagination.ts @@ -0,0 +1,94 @@ +/* + * 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 { useCallback, useEffect, useMemo } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import { useUrlParams } from './use_url_params'; +import { PAGE_SIZE_OPTIONS, Pagination, usePagination } from './use_pagination'; + +type SetUrlPagination = (pagination: Pagination) => void; +interface UrlPagination { + pagination: Pagination; + setPagination: SetUrlPagination; + pageSizeOptions: number[]; +} + +type UrlPaginationParams = Partial; + +/** + * Uses URL params for pagination and also persists those to the URL as they are updated + */ +export const useUrlPagination = (): UrlPagination => { + const location = useLocation(); + const history = useHistory(); + const { urlParams, toUrlParams } = useUrlParams(); + const urlPaginationParams = useMemo(() => { + return paginationFromUrlParams(urlParams); + }, [urlParams]); + const { pagination, pageSizeOptions, setPagination } = usePagination(urlPaginationParams); + + const setUrlPagination = useCallback( + ({ pageSize, currentPage }) => { + history.push({ + ...location, + search: toUrlParams({ + ...urlParams, + currentPage, + pageSize, + }), + }); + }, + [history, location, toUrlParams, urlParams] + ); + + useEffect(() => { + setPagination((prevState) => { + return { + ...prevState, + ...paginationFromUrlParams(urlParams), + }; + }); + }, [setPagination, urlParams]); + + return { + pagination, + setPagination: setUrlPagination, + pageSizeOptions, + }; +}; + +const paginationFromUrlParams = (urlParams: UrlPaginationParams): Pagination => { + const pagination: Pagination = { + pageSize: 20, + currentPage: 1, + }; + + // Search params can appear multiple times in the URL, in which case the value for them, + // once parsed, would be an array. In these case, we take the last value defined + pagination.currentPage = Number( + (Array.isArray(urlParams.currentPage) + ? urlParams.currentPage[urlParams.currentPage.length - 1] + : urlParams.currentPage) ?? pagination.currentPage + ); + pagination.pageSize = + Number( + (Array.isArray(urlParams.pageSize) + ? urlParams.pageSize[urlParams.pageSize.length - 1] + : urlParams.pageSize) ?? pagination.pageSize + ) ?? pagination.pageSize; + + // If Current Page is not a valid positive integer, set it to 1 + if (!Number.isFinite(pagination.currentPage) || pagination.currentPage < 1) { + pagination.currentPage = 1; + } + + // if pageSize is not one of the expected page sizes, reset it to 20 (default) + if (!PAGE_SIZE_OPTIONS.includes(pagination.pageSize)) { + pagination.pageSize = 20; + } + + return pagination; +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx index 4a737cf3c2d7..53463c14b9ce 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_agent_policy.tsx @@ -247,7 +247,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ id="xpack.fleet.createPackagePolicy.StepSelectPolicy.agentPolicyAgentsDescriptionText" defaultMessage="{count, plural, one {# agent} other {# agents}} are enrolled with the selected agent policy." values={{ - count: agentPoliciesById[selectedPolicyId]?.agents || 0, + count: agentPoliciesById[selectedPolicyId]?.agents ?? 0, }} /> ) : null @@ -283,7 +283,7 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ id="xpack.fleet.createPackagePolicy.StepSelectPolicy.agentPolicyAgentsCountText" defaultMessage="{count, plural, one {# agent} other {# agents}} enrolled" values={{ - count: agentPoliciesById[option.value!].agents || 0, + count: agentPoliciesById[option.value!]?.agents ?? 0, }} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx index 8c2fe838bfa4..0e8bb6b49e4a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx @@ -34,7 +34,7 @@ import { useUrlParams, useBreadcrumbs, } from '../../../hooks'; -import { SearchBar } from '../../../components'; +import { LinkAndRevision, SearchBar } from '../../../components'; import { LinkedAgentCount, AgentPolicyActionMenu } from '../components'; import { CreateAgentPolicyFlyout } from './components'; @@ -129,26 +129,13 @@ export const AgentPolicyListPage: React.FunctionComponent<{}> = () => { }), width: '20%', render: (name: string, agentPolicy: AgentPolicy) => ( - - - - {name || agentPolicy.id} - - - - - - - - + + {name || agentPolicy.id} + ), }, { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx index 40346cde7f50..62adad14a028 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/content.tsx @@ -17,7 +17,7 @@ import { SettingsPanel } from './settings_panel'; type ContentProps = PackageInfo & Pick; -const SideNavColumn = styled(LeftColumn)` +const LeftSideColumn = styled(LeftColumn)` /* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */ &&& { margin-top: 77px; @@ -30,15 +30,18 @@ const ContentFlexGroup = styled(EuiFlexGroup)` `; export function Content(props: ContentProps) { + const showRightColumn = props.panel !== 'policies'; return ( - - + + - - - + {showRightColumn && ( + + + + )} ); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx index 0e72693db9e2..aad8f9701923 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx @@ -237,8 +237,7 @@ export function Detail() { return (entries(PanelDisplayNames) .filter(([panelId]) => { return ( - panelId !== 'policies' || - (packageInfoData?.response.status === InstallStatus.installed && false) // Remove `false` when ready to implement policies tab + panelId !== 'policies' || packageInfoData?.response.status === InstallStatus.installed ); }) .map(([panelId, display]) => { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx index c32959638473..cbfab9ac9e5d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/layout.tsx @@ -6,31 +6,45 @@ import { EuiFlexItem } from '@elastic/eui'; import React, { FunctionComponent, ReactNode } from 'react'; +import { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item'; interface ColumnProps { children?: ReactNode; className?: string; + columnGrow?: FlexItemGrowSize; } -export const LeftColumn: FunctionComponent = ({ children, ...rest }) => { +export const LeftColumn: FunctionComponent = ({ + columnGrow = 2, + children, + ...rest +}) => { return ( - + {children} ); }; -export const CenterColumn: FunctionComponent = ({ children, ...rest }) => { +export const CenterColumn: FunctionComponent = ({ + columnGrow = 9, + children, + ...rest +}) => { return ( - + {children} ); }; -export const RightColumn: FunctionComponent = ({ children, ...rest }) => { +export const RightColumn: FunctionComponent = ({ + columnGrow = 3, + children, + ...rest +}) => { return ( - + {children} ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx index d97d891ac5e5..8609b08c9a77 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx @@ -4,11 +4,82 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { memo, ReactNode, useCallback, useMemo } from 'react'; import { Redirect } from 'react-router-dom'; +import { + CriteriaWithPagination, + EuiBasicTable, + EuiLink, + EuiTableFieldDataColumnType, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedRelative } from '@kbn/i18n/react'; import { useGetPackageInstallStatus } from '../../hooks'; import { InstallStatus } from '../../../../types'; import { useLink } from '../../../../hooks'; +import { + AGENT_SAVED_OBJECT_TYPE, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, +} from '../../../../../../../common/constants'; +import { useUrlPagination } from '../../../../hooks'; +import { + PackagePolicyAndAgentPolicy, + usePackagePoliciesWithAgentPolicy, +} from './use_package_policies_with_agent_policy'; +import { LinkAndRevision, LinkAndRevisionProps } from '../../../../components'; +import { Persona } from './persona'; + +const IntegrationDetailsLink = memo<{ + packagePolicy: PackagePolicyAndAgentPolicy['packagePolicy']; +}>(({ packagePolicy }) => { + const { getHref } = useLink(); + return ( + + {packagePolicy.name} + + ); +}); + +const AgentPolicyDetailLink = memo<{ + agentPolicyId: string; + revision: LinkAndRevisionProps['revision']; + children: ReactNode; +}>(({ agentPolicyId, revision, children }) => { + const { getHref } = useLink(); + return ( + + {children} + + ); +}); + +const PolicyAgentListLink = memo<{ agentPolicyId: string; children: ReactNode }>( + ({ agentPolicyId, children }) => { + const { getHref } = useLink(); + return ( + + {children} + + ); + } +); interface PackagePoliciesPanelProps { name: string; @@ -18,9 +89,118 @@ export const PackagePoliciesPanel = ({ name, version }: PackagePoliciesPanelProp const { getPath } = useLink(); const getPackageInstallStatus = useGetPackageInstallStatus(); const packageInstallStatus = getPackageInstallStatus(name); + const { pagination, pageSizeOptions, setPagination } = useUrlPagination(); + const { data } = usePackagePoliciesWithAgentPolicy({ + page: pagination.currentPage, + perPage: pagination.pageSize, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: ${name}`, + }); + + const handleTableOnChange = useCallback( + ({ page }: CriteriaWithPagination) => { + setPagination({ + currentPage: page.index + 1, + pageSize: page.size, + }); + }, + [setPagination] + ); + + const columns: Array> = useMemo( + () => [ + { + field: 'packagePolicy.name', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.name', { + defaultMessage: 'Integration', + }), + render(_, { packagePolicy }) { + return ; + }, + }, + { + field: 'packagePolicy.description', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.description', { + defaultMessage: 'Description', + }), + truncateText: true, + }, + { + field: 'packagePolicy.policy_id', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.agentPolicy', { + defaultMessage: 'Agent policy', + }), + truncateText: true, + render(id, { agentPolicy }) { + return ( + + {agentPolicy.name ?? id} + + ); + }, + }, + { + field: '', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.agentCount', { + defaultMessage: 'Agents', + }), + truncateText: true, + align: 'right', + width: '8ch', + render({ packagePolicy, agentPolicy }: PackagePolicyAndAgentPolicy) { + return ( + + {agentPolicy?.agents ?? 0} + + ); + }, + }, + { + field: 'packagePolicy.updated_by', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.updatedBy', { + defaultMessage: 'Last Updated By', + }), + truncateText: true, + render(updatedBy) { + return ; + }, + }, + { + field: 'packagePolicy.updated_at', + name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.updatedAt', { + defaultMessage: 'Last Updated', + }), + truncateText: true, + render(updatedAt: PackagePolicyAndAgentPolicy['packagePolicy']['updated_at']) { + return ( + + + + ); + }, + }, + ], + [] + ); + // if they arrive at this page and the package is not installed, send them to overview // this happens if they arrive with a direct url or they uninstall while on this tab - if (packageInstallStatus.status !== InstallStatus.installed) + if (packageInstallStatus.status !== InstallStatus.installed) { return ; - return null; + } + + return ( + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/persona.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/persona.tsx new file mode 100644 index 000000000000..06b3c7a9a409 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/persona.tsx @@ -0,0 +1,47 @@ +/* + * 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 { EuiAvatar, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import React, { CSSProperties, memo, useCallback } from 'react'; +import { EuiAvatarProps } from '@elastic/eui/src/components/avatar/avatar'; + +const MIN_WIDTH: CSSProperties = { minWidth: 0 }; + +/** + * Shows a user's name along with an avatar. Name is truncated if its wider than the availble space + */ +export const Persona = memo( + ({ name, className, 'data-test-subj': dataTestSubj, title, ...otherAvatarProps }) => { + const getTestId = useCallback( + (suffix) => { + if (dataTestSubj) { + return `${dataTestSubj}-${suffix}`; + } + }, + [dataTestSubj] + ); + return ( + + + + + + + {name} + + + + ); + } +); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/use_package_policies_with_agent_policy.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/use_package_policies_with_agent_policy.ts new file mode 100644 index 000000000000..d8a9d18e8a21 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/use_package_policies_with_agent_policy.ts @@ -0,0 +1,129 @@ +/* + * 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 { useEffect, useMemo, useState } from 'react'; +import { PackagePolicy } from '../../../../../../../common/types/models'; +import { + GetAgentPoliciesResponse, + GetAgentPoliciesResponseItem, +} from '../../../../../../../common/types/rest_spec'; +import { useGetPackagePolicies } from '../../../../hooks/use_request'; +import { + SendConditionalRequestConfig, + useConditionalRequest, +} from '../../../../hooks/use_request/use_request'; +import { agentPolicyRouteService } from '../../../../../../../common/services'; +import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../../common/constants'; +import { GetPackagePoliciesResponse } from '../../../../../../../common/types/rest_spec'; + +export interface PackagePolicyEnriched extends PackagePolicy { + _agentPolicy: GetAgentPoliciesResponseItem | undefined; +} + +export interface PackagePolicyAndAgentPolicy { + packagePolicy: PackagePolicy; + agentPolicy: GetAgentPoliciesResponseItem; +} + +type GetPackagePoliciesWithAgentPolicy = Omit & { + items: PackagePolicyAndAgentPolicy[]; +}; + +/** + * Works similar to `useGetAgentPolicies()`, except that it will add an additional property to + * each package policy named `_agentPolicy` which may hold the Agent Policy associated with the + * given package policy. + * @param query + */ +export const usePackagePoliciesWithAgentPolicy = ( + query: Parameters[0] +): { + isLoading: boolean; + error: Error | null; + data?: GetPackagePoliciesWithAgentPolicy; +} => { + const { + data: packagePoliciesData, + error, + isLoading: isLoadingPackagePolicies, + } = useGetPackagePolicies(query); + + const agentPoliciesFilter = useMemo(() => { + if (!packagePoliciesData?.items.length) { + return ''; + } + + // Build a list of package_policies for which we need Agent Policies for. Since some package + // policies can exist within the same Agent Policy, we don't need to (in some cases) include + // the entire list of package_policy ids. + const includedAgentPolicies = new Set(); + + return `${AGENT_POLICY_SAVED_OBJECT_TYPE}.package_policies: (${packagePoliciesData.items + .filter((packagePolicy) => { + if (includedAgentPolicies.has(packagePolicy.policy_id)) { + return false; + } + includedAgentPolicies.add(packagePolicy.policy_id); + return true; + }) + .map((packagePolicy) => packagePolicy.id) + .join(' or ')}) `; + }, [packagePoliciesData]); + + const { + data: agentPoliciesData, + isLoading: isLoadingAgentPolicies, + } = useConditionalRequest({ + path: agentPolicyRouteService.getListPath(), + method: 'get', + query: { + perPage: 100, + kuery: agentPoliciesFilter, + }, + shouldSendRequest: !!packagePoliciesData?.items.length, + } as SendConditionalRequestConfig); + + const [enrichedData, setEnrichedData] = useState(); + + useEffect(() => { + if (isLoadingPackagePolicies || isLoadingAgentPolicies) { + return; + } + + if (!packagePoliciesData?.items) { + setEnrichedData(undefined); + return; + } + + const agentPoliciesById: Record = {}; + + if (agentPoliciesData?.items) { + for (const agentPolicy of agentPoliciesData.items) { + agentPoliciesById[agentPolicy.id] = agentPolicy; + } + } + + const updatedPackageData: PackagePolicyAndAgentPolicy[] = packagePoliciesData.items.map( + (packagePolicy) => { + return { + packagePolicy, + agentPolicy: agentPoliciesById[packagePolicy.policy_id], + }; + } + ); + + setEnrichedData({ + ...packagePoliciesData, + items: updatedPackageData, + }); + }, [isLoadingAgentPolicies, isLoadingPackagePolicies, packagePoliciesData, agentPoliciesData]); + + return { + data: enrichedData, + error, + isLoading: isLoadingPackagePolicies || isLoadingAgentPolicies, + }; +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 99e35d5caa62..9a28e0e53bef 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7264,7 +7264,6 @@ "xpack.fleet.agentPolicyList.pageSubtitle": "エージェントポリシーを使用すると、エージェントとエージェントが収集するデータを管理できます。", "xpack.fleet.agentPolicyList.pageTitle": "エージェントポリシー", "xpack.fleet.agentPolicyList.reloadAgentPoliciesButtonText": "再読み込み", - "xpack.fleet.agentPolicyList.revisionNumber": "rev. {revNumber}", "xpack.fleet.agentPolicyList.updatedOnColumnTitle": "最終更新日", "xpack.fleet.agentReassignPolicy.cancelButtonLabel": "キャンセル", "xpack.fleet.agentReassignPolicy.continueButtonLabel": "ポリシーの割り当て", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d58d1063b9ae..66a00c30bd3b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7271,7 +7271,6 @@ "xpack.fleet.agentPolicyList.pageSubtitle": "使用代理策略管理代理及其收集的数据。", "xpack.fleet.agentPolicyList.pageTitle": "代理策略", "xpack.fleet.agentPolicyList.reloadAgentPoliciesButtonText": "重新加载", - "xpack.fleet.agentPolicyList.revisionNumber": "修订版 {revNumber}", "xpack.fleet.agentPolicyList.updatedOnColumnTitle": "上次更新时间", "xpack.fleet.agentReassignPolicy.cancelButtonLabel": "取消", "xpack.fleet.agentReassignPolicy.continueButtonLabel": "分配策略", From 72f36b41f926c114831941d4683559b860ed67e7 Mon Sep 17 00:00:00 2001 From: Dan Panzarella Date: Tue, 24 Nov 2020 09:59:41 -0500 Subject: [PATCH 22/89] [Security Solution] Add endpoint policy revision number (#83982) --- .../view/details/endpoint_details.tsx | 28 ++++++++++++++++++- .../pages/endpoint_hosts/view/index.test.tsx | 21 ++++++++++++++ .../pages/endpoint_hosts/view/index.tsx | 18 +++++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index 02d41e7dea1a..967a0d3e25b2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -14,6 +14,8 @@ import { EuiListGroupItem, EuiIcon, EuiText, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -139,7 +141,31 @@ export const EndpointDetails = memo( > {details.Endpoint.policy.applied.name} - {isPolicyOutOfDate(details.Endpoint.policy.applied, policyInfo) && } + + {details.Endpoint.policy.applied.endpoint_policy_version && ( + + + + + + )} + {isPolicyOutOfDate(details.Endpoint.policy.applied, policyInfo) && ( + + + + )} + ), }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 69889d3d0a88..487f5ddab550 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -220,6 +220,7 @@ describe('when on the list page', () => { HostInfo['metadata']['Endpoint']['policy']['applied']['status'] > = []; let firstPolicyID: string; + let firstPolicyRev: number; beforeEach(() => { reactTestingLibrary.act(() => { const mockedEndpointData = mockEndpointResultList({ total: 4 }); @@ -227,6 +228,7 @@ describe('when on the list page', () => { const queryStrategyVersion = mockedEndpointData.query_strategy_version; firstPolicyID = hostListData[0].metadata.Endpoint.policy.applied.id; + firstPolicyRev = hostListData[0].metadata.Endpoint.policy.applied.endpoint_policy_version; // add ability to change (immutable) policy type DeepMutable = { -readonly [P in keyof T]: DeepMutable }; @@ -402,6 +404,16 @@ describe('when on the list page', () => { }); }); }); + + it('should show revision number', async () => { + const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedEndpointList'); + }); + const firstPolicyRevElement = (await renderResult.findAllByTestId('policyListRevNo'))[0]; + expect(firstPolicyRevElement).not.toBeNull(); + expect(firstPolicyRevElement.textContent).toEqual(`rev. ${firstPolicyRev}`); + }); }); }); @@ -586,6 +598,15 @@ describe('when on the list page', () => { ); }); + it('should display policy revision number', async () => { + const renderResult = await renderAndWaitForData(); + const policyDetailsRevElement = await renderResult.findByTestId('policyDetailsRevNo'); + expect(policyDetailsRevElement).not.toBeNull(); + expect(policyDetailsRevElement.textContent).toEqual( + `rev. ${hostDetails.metadata.Endpoint.policy.applied.endpoint_policy_version}` + ); + }); + it('should update the URL when policy name link is clicked', async () => { const renderResult = await renderAndWaitForData(); const policyDetailsLink = await renderResult.findByTestId('policyDetailsValue'); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index a759c9de4141..eaa748d781b9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -219,6 +219,8 @@ export const EndpointList = () => { const NOOP = useCallback(() => {}, []); + const PAD_LEFT: React.CSSProperties = { paddingLeft: '6px' }; + const handleDeployEndpointsClick = useNavigateToAppEventHandler( 'fleet', { @@ -337,8 +339,22 @@ export const EndpointList = () => { {policy.name} + {policy.endpoint_policy_version && ( + + + + )} {isPolicyOutOfDate(policy, item.policy_info) && ( - + )} ); From 5ec6fe315f3894c6571864d542d151f3b8c5e9b5 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 24 Nov 2020 18:04:33 +0300 Subject: [PATCH 23/89] [DX] Bump TS version to v4.1 (#83397) * bump version to 4.1.1-rc * fix code to run kbn bootstrap * fix errors * DO NOT MERGE. mute errors and ping teams to fix them * Address EuiSelectableProps configuration in discover sidebar * use explicit type for EuiSelectable * update to ts v4.1.2 * fix ts error in EuiSelectable * update docs * update prettier with ts version support * Revert "update prettier with ts version support" This reverts commit 3de48db3ec45011142e3ba6afda48d33bf5bff11. * address another new problem Co-authored-by: Chandler Prall --- ...na-plugin-plugins-data-public.searchbar.md | 4 +-- package.json | 4 +-- packages/kbn-es-archiver/src/cli.ts | 2 +- .../application/application_service.test.ts | 2 +- .../application_service.test.tsx | 8 ++--- .../application/ui/app_container.test.tsx | 2 +- .../public/ui_settings/ui_settings_api.ts | 2 +- .../client/cluster_client.test.ts | 4 +-- .../logging/appenders/file/file_appender.ts | 2 +- .../management_app/components/field/field.tsx | 2 +- .../create_streaming_batched_function.test.ts | 2 +- .../legacy_core_editor/legacy_core_editor.ts | 4 +-- .../console/server/lib/proxy_request.ts | 2 +- .../actions/expand_panel_action.tsx | 10 +++++-- src/plugins/data/public/public.api.md | 4 +-- .../sidebar/change_indexpattern.tsx | 4 +-- .../public/lib/containers/container.ts | 3 +- .../public/lib/state_transfer/types.ts | 2 +- .../contact_card_embeddable_factory.tsx | 1 + .../forms/hook_form_lib/hooks/use_form.ts | 2 +- .../expression_types/expression_type.test.ts | 2 +- src/plugins/expressions/public/render.test.ts | 2 +- .../lib/ensure_minimum_time.test.ts | 2 +- src/plugins/kibana_utils/common/of.test.ts | 4 +-- .../utils/use/use_visualize_app_state.test.ts | 2 +- test/functional/services/remote/remote.ts | 2 +- .../public/components/inputs/code_editor.tsx | 1 + .../public/components/inputs/input.tsx | 1 + .../public/components/inputs/multi_input.tsx | 1 + .../components/inputs/password_input.tsx | 1 + .../functions/browser/location.ts | 2 +- .../canvas/shareable_runtime/test/utils.ts | 2 +- .../abstract_explore_data_action.ts | 2 +- .../plugins/fleet/scripts/dev_agent/script.ts | 2 +- .../agents/checkin/state_new_actions.test.ts | 4 +-- .../server/services/epm/archive/extract.ts | 4 +-- x-pack/plugins/infra/server/plugin.ts | 2 +- .../config_panel/config_panel.tsx | 7 ++--- .../editor_frame/config_panel/layer_panel.tsx | 4 +-- .../editor_frame/state_helpers.ts | 30 +++++++++---------- .../change_indexpattern.tsx | 10 +++++-- .../server/on_pre_response_handler.test.ts | 2 +- .../public/common/hooks/use_async.test.ts | 2 +- .../server/browsers/download/download.ts | 2 +- .../export_types/csv/execute_job.test.ts | 2 +- .../server/lib/screenshots/wait_for_render.ts | 2 +- .../api_keys_grid/api_keys_grid_page.test.tsx | 2 +- .../role_mappings_grid_page.tsx | 2 +- .../roles/roles_grid/roles_grid_page.test.tsx | 2 +- ...thorized_response_http_interceptor.test.ts | 2 +- .../common/utils/clone_http_fetch_query.ts | 2 +- .../rules/all/columns.test.tsx | 4 +-- .../state/async_resource_state.ts | 4 +-- .../pages/trusted_apps/store/selectors.ts | 2 +- .../test_utilities/simulator/index.tsx | 2 +- .../public/resolver/view/use_camera.test.tsx | 2 +- .../timelines/components/duration/index.tsx | 1 + .../duration_event_start_end.tsx | 1 + .../timeline/body/renderers/bytes/index.tsx | 1 + .../timeline/query_bar/index.test.tsx | 2 +- .../routes/utils/check_timelines_status.ts | 4 +-- .../type_settings/hdfs_settings.tsx | 1 + .../repository_table/repository_table.tsx | 6 +++- .../server/lib/bulk_operation_buffer.test.ts | 4 +-- .../configuration_statistics.test.ts | 2 +- .../monitoring_stats_stream.test.ts | 4 +-- .../monitoring/task_run_statistics.test.ts | 10 +++---- .../monitoring/workload_statistics.test.ts | 18 +++++------ .../server/polling/observable_monitor.test.ts | 8 ++--- .../detection_engine_api_integration/utils.ts | 2 +- .../services/app_search_client.ts | 2 +- x-pack/test/lists_api_integration/utils.ts | 2 +- .../tests/oidc/implicit_flow/oidc_auth.ts | 2 +- .../tests/token/header.ts | 2 +- .../tests/token/session.ts | 2 +- yarn.lock | 8 ++--- 76 files changed, 146 insertions(+), 123 deletions(-) diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index b886aafcfc00..2fd84730957b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "screenTitle" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "indexPatterns" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/package.json b/package.json index d24a0c2700a0..39149c801da4 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "**/prismjs": "1.22.0", "**/request": "^2.88.2", "**/trim": "0.0.3", - "**/typescript": "4.0.2" + "**/typescript": "4.1.2" }, "engines": { "node": "12.19.1", @@ -827,7 +827,7 @@ "topojson-client": "3.0.0", "ts-loader": "^7.0.5", "tsd": "^0.13.1", - "typescript": "4.0.2", + "typescript": "4.1.2", "typescript-fsa": "^3.0.0", "typescript-fsa-reducers": "^1.2.1", "unlazy-loader": "^0.1.3", diff --git a/packages/kbn-es-archiver/src/cli.ts b/packages/kbn-es-archiver/src/cli.ts index 87df07fe865b..d65f5a5b23cd 100644 --- a/packages/kbn-es-archiver/src/cli.ts +++ b/packages/kbn-es-archiver/src/cli.ts @@ -228,7 +228,7 @@ export function runCli() { output: process.stdout, }); - await new Promise((resolveInput) => { + await new Promise((resolveInput) => { rl.question(`Press enter when you're done`, () => { rl.close(); resolveInput(); diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index afcebc06506c..cd186f87b3a8 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -697,7 +697,7 @@ describe('#start()', () => { // Create an app and a promise that allows us to control when the app completes mounting const createWaitingApp = (props: Partial): [App, () => void] => { let finishMount: () => void; - const mountPromise = new Promise((resolve) => (finishMount = resolve)); + const mountPromise = new Promise((resolve) => (finishMount = resolve)); const app = { id: 'some-id', title: 'some-title', diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index 82933576bc49..2ccb8ec64f91 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -66,7 +66,7 @@ describe('ApplicationService', () => { const { register } = service.setup(setupDeps); let resolveMount: () => void; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { resolveMount = resolve; }); @@ -100,7 +100,7 @@ describe('ApplicationService', () => { const { register } = service.setup(setupDeps); let resolveMount: () => void; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { resolveMount = resolve; }); @@ -442,7 +442,7 @@ describe('ApplicationService', () => { const { register } = service.setup(setupDeps); let resolveMount: () => void; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { resolveMount = resolve; }); @@ -480,7 +480,7 @@ describe('ApplicationService', () => { const { register } = service.setup(setupDeps); let resolveMount: () => void; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve) => { resolveMount = resolve; }); diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index f6cde54e6f50..50c332dacc34 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -38,7 +38,7 @@ describe('AppContainer', () => { }); const flushPromises = async () => { - await new Promise(async (resolve) => { + await new Promise(async (resolve) => { setImmediate(() => resolve()); }); }; diff --git a/src/core/public/ui_settings/ui_settings_api.ts b/src/core/public/ui_settings/ui_settings_api.ts index c5efced0a41e..175f70a05ec7 100644 --- a/src/core/public/ui_settings/ui_settings_api.ts +++ b/src/core/public/ui_settings/ui_settings_api.ts @@ -70,7 +70,7 @@ export class UiSettingsApi { if (error) { reject(error); } else { - resolve(resp); + resolve(resp!); } }, }; diff --git a/src/core/server/elasticsearch/client/cluster_client.test.ts b/src/core/server/elasticsearch/client/cluster_client.test.ts index 429fea65704d..1127619040ff 100644 --- a/src/core/server/elasticsearch/client/cluster_client.test.ts +++ b/src/core/server/elasticsearch/client/cluster_client.test.ts @@ -419,7 +419,7 @@ describe('ClusterClient', () => { let closeScopedClient: () => void; internalClient.close.mockReturnValue( - new Promise((resolve) => { + new Promise((resolve) => { closeInternalClient = resolve; }).then(() => { expect(clusterClientClosed).toBe(false); @@ -427,7 +427,7 @@ describe('ClusterClient', () => { }) ); scopedClient.close.mockReturnValue( - new Promise((resolve) => { + new Promise((resolve) => { closeScopedClient = resolve; }).then(() => { expect(clusterClientClosed).toBe(false); diff --git a/src/core/server/logging/appenders/file/file_appender.ts b/src/core/server/logging/appenders/file/file_appender.ts index c86ea4972324..2d9ac1214806 100644 --- a/src/core/server/logging/appenders/file/file_appender.ts +++ b/src/core/server/logging/appenders/file/file_appender.ts @@ -71,7 +71,7 @@ export class FileAppender implements DisposableAppender { * Disposes `FileAppender`. Waits for the underlying file stream to be completely flushed and closed. */ public async dispose() { - await new Promise((resolve) => { + await new Promise((resolve) => { if (this.outputStream === undefined) { return resolve(); } diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index e9fa2833c3db..ad938d339f68 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -263,7 +263,7 @@ export class Field extends PureComponent { return new Promise((resolve, reject) => { reader.onload = () => { - resolve(reader.result || undefined); + resolve(reader.result!); }; reader.onerror = (err) => { reject(err); diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts index da6c940c48d0..d7dde8f1b93d 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts @@ -30,7 +30,7 @@ const getPromiseState = (promise: Promise): Promise<'resolved' | 'rejec () => resolve('rejected') ) ), - new Promise<'pending'>((resolve) => resolve()).then(() => 'pending'), + new Promise<'pending'>((resolve) => resolve('pending')).then(() => 'pending'), ]); const isPending = (promise: Promise): Promise => diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts index 84b12c97f185..34f43886df66 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts @@ -74,7 +74,7 @@ export class LegacyCoreEditor implements CoreEditor { // dirty check for tokenizer state, uses a lot less cycles // than listening for tokenizerUpdate waitForLatestTokens(): Promise { - return new Promise((resolve) => { + return new Promise((resolve) => { const session = this.editor.getSession(); const checkInterval = 25; @@ -239,7 +239,7 @@ export class LegacyCoreEditor implements CoreEditor { private forceRetokenize() { const session = this.editor.getSession(); - return new Promise((resolve) => { + return new Promise((resolve) => { // force update of tokens, but not on this thread to allow for ace rendering. setTimeout(function () { let i; diff --git a/src/plugins/console/server/lib/proxy_request.ts b/src/plugins/console/server/lib/proxy_request.ts index 27e19d920ad1..b6d7fc97f49a 100644 --- a/src/plugins/console/server/lib/proxy_request.ts +++ b/src/plugins/console/server/lib/proxy_request.ts @@ -110,7 +110,7 @@ export const proxyRequest = ({ if (!resolved) { timeoutReject(Boom.gatewayTimeout('Client request timeout')); } else { - timeoutResolve(); + timeoutResolve(undefined); } }, timeout); }); diff --git a/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx b/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx index 933d2766d13f..dcce38cdf94c 100644 --- a/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx @@ -20,7 +20,11 @@ import { i18n } from '@kbn/i18n'; import { IEmbeddable } from '../../embeddable_plugin'; import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; -import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable'; +import { + DASHBOARD_CONTAINER_TYPE, + DashboardContainer, + DashboardContainerInput, +} from '../embeddable'; export const ACTION_EXPAND_PANEL = 'togglePanel'; @@ -33,7 +37,9 @@ function isExpanded(embeddable: IEmbeddable) { throw new IncompatibleActionError(); } - return embeddable.id === embeddable.parent.getInput().expandedPanelId; + return ( + embeddable.id === (embeddable.parent.getInput() as DashboardContainerInput).expandedPanelId + ); } export interface ExpandPanelActionContext { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index e1af3cc1d1b4..2c47ecb27184 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2047,8 +2047,8 @@ export const search: { // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "screenTitle" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "indexPatterns" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; }; // Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx b/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx index 4a539b618f81..e44c05b3a88a 100644 --- a/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx +++ b/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx @@ -47,7 +47,7 @@ export function ChangeIndexPattern({ indexPatternRefs: IndexPatternRef[]; onChangeIndexPattern: (newId: string) => void; indexPatternId?: string; - selectableProps?: EuiSelectableProps; + selectableProps?: EuiSelectableProps<{ value: string }>; }) { const [isPopoverOpen, setPopoverIsOpen] = useState(false); @@ -86,7 +86,7 @@ export function ChangeIndexPattern({ defaultMessage: 'Change index pattern', })} - data-test-subj="indexPattern-switcher" {...selectableProps} searchable diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 292d7d3bf7a1..1426ade147d3 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -171,7 +171,7 @@ export abstract class Container< return this.children[id] as TEmbeddable; } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => { if (this.output.embeddableLoaded[id]) { subscription.unsubscribe(); @@ -181,6 +181,7 @@ export abstract class Container< // If we hit this, the panel was removed before the embeddable finished loading. if (this.input.panels[id] === undefined) { subscription.unsubscribe(); + // @ts-expect-error undefined in not assignable to TEmbeddable | ErrorEmbeddable resolve(undefined); } }); diff --git a/src/plugins/embeddable/public/lib/state_transfer/types.ts b/src/plugins/embeddable/public/lib/state_transfer/types.ts index d8b4f4801bba..d36954528dbf 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/types.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/types.ts @@ -53,7 +53,7 @@ export function isEmbeddablePackageState(state: unknown): state is EmbeddablePac function ensureFieldOfTypeExists(key: string, obj: unknown, type?: string): boolean { return ( - obj && + Boolean(obj) && key in (obj as { [key: string]: unknown }) && (!type || typeof (obj as { [key: string]: unknown })[key] === type) ); diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx index 893b6b04e50b..b6a7137c1e42 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card_embeddable_factory.tsx @@ -56,6 +56,7 @@ export class ContactCardEmbeddableFactory { modalSession.close(); + // @ts-expect-error resolve(undefined); }} onCreate={(input: { firstName: string; lastName?: string }) => { diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index 869d1fac54b1..a509331ef190 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -149,7 +149,7 @@ export function useForm( return; } - return new Promise((resolve) => { + return new Promise((resolve) => { setTimeout(() => { areSomeFieldValidating = fieldsToArray().some((field) => field.isValidating); if (areSomeFieldValidating) { diff --git a/src/plugins/expressions/common/expression_types/expression_type.test.ts b/src/plugins/expressions/common/expression_types/expression_type.test.ts index b94d9a305121..2976697e0299 100644 --- a/src/plugins/expressions/common/expression_types/expression_type.test.ts +++ b/src/plugins/expressions/common/expression_types/expression_type.test.ts @@ -44,7 +44,7 @@ export const render: ExpressionTypeDefinition<'render', ExpressionValueRender(v: T): ExpressionValueRender => ({ - type: name, + type: 'render', as: 'debug', value: v, }), diff --git a/src/plugins/expressions/public/render.test.ts b/src/plugins/expressions/public/render.test.ts index 97a37d49147e..c44683f6779c 100644 --- a/src/plugins/expressions/public/render.test.ts +++ b/src/plugins/expressions/public/render.test.ts @@ -129,7 +129,7 @@ describe('ExpressionRenderHandler', () => { it('sends a next observable once rendering is complete', () => { const expressionRenderHandler = new ExpressionRenderHandler(element); expect.assertions(1); - return new Promise((resolve) => { + return new Promise((resolve) => { expressionRenderHandler.render$.subscribe((renderCount) => { expect(renderCount).toBe(1); resolve(); diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/ensure_minimum_time.test.ts b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/ensure_minimum_time.test.ts index a38f3cbd8fe8..5a202bff53b6 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/ensure_minimum_time.test.ts +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/lib/ensure_minimum_time.test.ts @@ -38,7 +38,7 @@ describe('ensureMinimumTime', () => { it('resolves in the amount of time provided, at minimum', async (done) => { const startTime = new Date().getTime(); - const promise = new Promise((resolve) => resolve()); + const promise = new Promise((resolve) => resolve()); await ensureMinimumTime(promise, 100); const endTime = new Date().getTime(); expect(endTime - startTime).toBeGreaterThanOrEqual(100); diff --git a/src/plugins/kibana_utils/common/of.test.ts b/src/plugins/kibana_utils/common/of.test.ts index 9ff8997f637e..a262bfa708d0 100644 --- a/src/plugins/kibana_utils/common/of.test.ts +++ b/src/plugins/kibana_utils/common/of.test.ts @@ -21,7 +21,7 @@ import { of } from './of'; describe('of()', () => { describe('when promise resolves', () => { - const promise = new Promise((resolve) => resolve()).then(() => 123); + const promise = new Promise((resolve) => resolve()).then(() => 123); test('first member of 3-tuple is the promise value', async () => { const [result] = await of(promise); @@ -40,7 +40,7 @@ describe('of()', () => { }); describe('when promise rejects', () => { - const promise = new Promise((resolve) => resolve()).then(() => { + const promise = new Promise((resolve) => resolve()).then(() => { // eslint-disable-next-line no-throw-literal throw 123; }); diff --git a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts index 39a2db12ffad..7f971d44af96 100644 --- a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts +++ b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts @@ -199,7 +199,7 @@ describe('useVisualizeAppState', () => { renderHook(() => useVisualizeAppState(mockServices, eventEmitter, savedVisInstance)); - await new Promise((res) => { + await new Promise((res) => { setTimeout(() => res()); }); diff --git a/test/functional/services/remote/remote.ts b/test/functional/services/remote/remote.ts index a45403e31095..94511b0bcf5d 100644 --- a/test/functional/services/remote/remote.ts +++ b/test/functional/services/remote/remote.ts @@ -130,7 +130,7 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { const coverageJson = await driver .executeScript('return window.__coverage__') .catch(() => undefined) - .then((coverage) => coverage && JSON.stringify(coverage)); + .then((coverage) => (coverage ? JSON.stringify(coverage) : undefined)); if (coverageJson) { writeCoverage(coverageJson); } diff --git a/x-pack/plugins/beats_management/public/components/inputs/code_editor.tsx b/x-pack/plugins/beats_management/public/components/inputs/code_editor.tsx index 46ea90a9c1b3..78eecf798486 100644 --- a/x-pack/plugins/beats_management/public/components/inputs/code_editor.tsx +++ b/x-pack/plugins/beats_management/public/components/inputs/code_editor.tsx @@ -68,6 +68,7 @@ class CodeEditor extends Component< public render() { const { + name, id, label, isReadOnly, diff --git a/x-pack/plugins/beats_management/public/components/inputs/input.tsx b/x-pack/plugins/beats_management/public/components/inputs/input.tsx index 29cdcfccfc75..17f2f95070c5 100644 --- a/x-pack/plugins/beats_management/public/components/inputs/input.tsx +++ b/x-pack/plugins/beats_management/public/components/inputs/input.tsx @@ -71,6 +71,7 @@ class FieldText extends Component< public render() { const { + name, id, required, label, diff --git a/x-pack/plugins/beats_management/public/components/inputs/multi_input.tsx b/x-pack/plugins/beats_management/public/components/inputs/multi_input.tsx index 16bcf1b3b9a0..ed0d67bb2214 100644 --- a/x-pack/plugins/beats_management/public/components/inputs/multi_input.tsx +++ b/x-pack/plugins/beats_management/public/components/inputs/multi_input.tsx @@ -73,6 +73,7 @@ class MultiFieldText extends Component< public render() { const { + name, id, required, label, diff --git a/x-pack/plugins/beats_management/public/components/inputs/password_input.tsx b/x-pack/plugins/beats_management/public/components/inputs/password_input.tsx index 30f4cb85fb58..edb8cf6ab3ab 100644 --- a/x-pack/plugins/beats_management/public/components/inputs/password_input.tsx +++ b/x-pack/plugins/beats_management/public/components/inputs/password_input.tsx @@ -67,6 +67,7 @@ class FieldPassword extends Component< public render() { const { + name, id, required, label, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts index 5c0ca74f5225..4eed89b95132 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts @@ -28,7 +28,7 @@ export function location(): ExpressionFunctionDefinition<'location', null, {}, P help, fn: () => { return new Promise((resolve) => { - function createLocation(geoposition: Position) { + function createLocation(geoposition: GeolocationPosition) { const { latitude, longitude } = geoposition.coords; return resolve({ type: 'datatable', diff --git a/x-pack/plugins/canvas/shareable_runtime/test/utils.ts b/x-pack/plugins/canvas/shareable_runtime/test/utils.ts index 5e65594972da..939343b6a28c 100644 --- a/x-pack/plugins/canvas/shareable_runtime/test/utils.ts +++ b/x-pack/plugins/canvas/shareable_runtime/test/utils.ts @@ -21,7 +21,7 @@ export const takeMountedSnapshot = (mountedComponent: ReactWrapper<{}, {}, Compo }; export const waitFor = (fn: () => boolean, stepMs = 100, failAfterMs = 1000) => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let waitForTimeout: NodeJS.Timeout; const tryCondition = () => { diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts index 40e7691e621f..30de6c080271 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts @@ -42,7 +42,7 @@ export abstract class AbstractExploreDataAction; + protected abstract getUrl(context: Context): Promise; public async isCompatible({ embeddable }: Context): Promise { if (!embeddable) return false; diff --git a/x-pack/plugins/fleet/scripts/dev_agent/script.ts b/x-pack/plugins/fleet/scripts/dev_agent/script.ts index 18ce12794776..3babb03c2dac 100644 --- a/x-pack/plugins/fleet/scripts/dev_agent/script.ts +++ b/x-pack/plugins/fleet/scripts/dev_agent/script.ts @@ -45,7 +45,7 @@ run( while (!closing) { await checkin(kibanaUrl, agent, log); - await new Promise((resolve, reject) => setTimeout(() => resolve(), CHECKIN_INTERVAL)); + await new Promise((resolve, reject) => setTimeout(() => resolve(), CHECKIN_INTERVAL)); } }, { diff --git a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts index 28ddf9704bd9..9c87eaa1859c 100644 --- a/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/checkin/state_new_actions.test.ts @@ -85,9 +85,9 @@ describe('test agent checkin new action services', () => { it('should not fetch actions concurrently', async () => { const observable = createNewActionsSharedObservable(); - const resolves: Array<() => void> = []; + const resolves: Array<(value?: any) => void> = []; getMockedNewActionSince().mockImplementation(() => { - return new Promise((resolve) => { + return new Promise((resolve) => { resolves.push(resolve); }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/archive/extract.ts b/x-pack/plugins/fleet/server/services/epm/archive/extract.ts index 6ac81a25dfc2..1e8f7ce416df 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/extract.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/extract.ts @@ -69,7 +69,7 @@ export function getBufferExtractor( function yauzlFromBuffer(buffer: Buffer, opts: yauzl.Options): Promise { return new Promise((resolve, reject) => yauzl.fromBuffer(buffer, opts, (err?: Error, handle?: yauzl.ZipFile) => - err ? reject(err) : resolve(handle) + err ? reject(err) : resolve(handle!) ) ); } @@ -80,7 +80,7 @@ function getZipReadStream( ): Promise { return new Promise((resolve, reject) => zipfile.openReadStream(entry, (err?: Error, readStream?: NodeJS.ReadableStream) => - err ? reject(err) : resolve(readStream) + err ? reject(err) : resolve(readStream!) ) ); } diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index a3b4cb604231..ef09dbfcb267 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -88,7 +88,7 @@ export class InfraServerPlugin { } async setup(core: CoreSetup, plugins: InfraServerPluginDeps) { - await new Promise((resolve) => { + await new Promise((resolve) => { this.config$.subscribe((configValue) => { this.config = configValue; resolve(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index 6b7e5ba8ea89..93b4a4e3bea2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -19,10 +19,9 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config const activeVisualization = props.visualizationMap[props.activeVisualizationId || '']; const { visualizationState } = props; - return ( - activeVisualization && - visualizationState && - ); + return activeVisualization && visualizationState ? ( + + ) : null; }); function LayerPanels( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index f5b31fb88116..67c6068dd4d9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -37,9 +37,9 @@ function isConfiguration( value: unknown ): value is { columnId: string; groupId: string; layerId: string } { return ( - value && + Boolean(value) && typeof value === 'object' && - 'columnId' in value && + 'columnId' in value! && 'groupId' in value && 'layerId' in value ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 0c96fc45de12..9c5eafc300ab 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -110,21 +110,21 @@ export const validateDatasourceAndVisualization = ( longMessage: string; }> | undefined => { - const layersGroups = - currentVisualizationState && - currentVisualization - ?.getLayerIds(currentVisualizationState) - .reduce>((memo, layerId) => { - const groups = currentVisualization?.getConfiguration({ - frame: frameAPI, - layerId, - state: currentVisualizationState, - }).groups; - if (groups) { - memo[layerId] = groups; - } - return memo; - }, {}); + const layersGroups = currentVisualizationState + ? currentVisualization + ?.getLayerIds(currentVisualizationState) + .reduce>((memo, layerId) => { + const groups = currentVisualization?.getConfiguration({ + frame: frameAPI, + layerId, + state: currentVisualizationState, + }).groups; + if (groups) { + memo[layerId] = groups; + } + return memo; + }, {}) + : undefined; const datasourceValidationErrors = currentDatasourceState ? currentDataSource?.getErrorMessages(currentDatasourceState, layersGroups) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx index 3c9e19d30d38..25cb34d19beb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx @@ -6,8 +6,7 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { EuiPopover, EuiPopoverTitle, EuiSelectable } from '@elastic/eui'; -import { EuiSelectableProps } from '@elastic/eui/src/components/selectable/selectable'; +import { EuiPopover, EuiPopoverTitle, EuiSelectable, EuiSelectableProps } from '@elastic/eui'; import { IndexPatternRef } from './types'; import { trackUiEvent } from '../lens_ui_telemetry'; import { ToolbarButtonProps, ToolbarButton } from '../shared_components'; @@ -63,7 +62,12 @@ export function ChangeIndexPattern({ defaultMessage: 'Change index pattern', })} - {...selectableProps} searchable singleSelection="always" diff --git a/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts b/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts index af3ec42ab4ec..b21a821ec6a7 100644 --- a/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts +++ b/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts @@ -46,7 +46,7 @@ describe('createOnPreResponseHandler', () => { const license$ = new BehaviorSubject(licenseMock.createLicense({ signature: 'foo' })); const refresh = jest.fn().mockImplementation( () => - new Promise((resolve) => { + new Promise((resolve) => { setTimeout(() => { license$.next(updatedLicense); resolve(); diff --git a/x-pack/plugins/lists/public/common/hooks/use_async.test.ts b/x-pack/plugins/lists/public/common/hooks/use_async.test.ts index 33f28cfc9729..6ae7aa25fc3b 100644 --- a/x-pack/plugins/lists/public/common/hooks/use_async.test.ts +++ b/x-pack/plugins/lists/public/common/hooks/use_async.test.ts @@ -80,7 +80,7 @@ describe('useAsync', () => { it('populates the loading state while the function is pending', async () => { let resolve: () => void; - fn.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve))); + fn.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve))); const { result, waitForNextUpdate } = renderHook(() => useAsync(fn)); diff --git a/x-pack/plugins/reporting/server/browsers/download/download.ts b/x-pack/plugins/reporting/server/browsers/download/download.ts index b4b303416fac..a8637679a76d 100644 --- a/x-pack/plugins/reporting/server/browsers/download/download.ts +++ b/x-pack/plugins/reporting/server/browsers/download/download.ts @@ -36,7 +36,7 @@ export async function download(url: string, path: string, logger: GenericLevelLo hash.update(chunk); }); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { resp.data .on('error', (err: Error) => { logger.error(err); diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts index 72b42143a24f..ea65262c090e 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts @@ -24,7 +24,7 @@ import { createMockReportingCore } from '../../test_helpers'; import { runTaskFnFactory } from './execute_job'; import { TaskPayloadCSV } from './types'; -const delay = (ms: number) => new Promise((resolve) => setTimeout(() => resolve(), ms)); +const delay = (ms: number) => new Promise((resolve) => setTimeout(() => resolve(), ms)); const puid = new Puid(); const getRandomScrollId = () => { diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts index edd4f71b2ada..429c5d88d7db 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts @@ -36,7 +36,7 @@ export const waitForRenderComplete = async ( const renderedTasks = []; function waitForRender(visualization: Element) { - return new Promise((resolve) => { + return new Promise((resolve) => { visualization.addEventListener('renderComplete', () => resolve()); }); } diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx index c58cb12f03a1..68daf427677f 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx @@ -25,7 +25,7 @@ const waitForRender = async ( wrapper: ReactWrapper, condition: (wrapper: ReactWrapper) => boolean ) => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const interval = setInterval(async () => { await Promise.resolve(); wrapper.update(); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx index 98e59eb95f0d..7e83c3654cac 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.tsx @@ -413,7 +413,7 @@ export class RoleMappingsGridPage extends Component { 'xpack.security.management.roleMappings.actionDeleteAriaLabel', { defaultMessage: `Delete '{name}'`, - values: { name }, + values: { name: record.name }, } )} iconType="trash" diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx index cfb57f8a8a8d..69229e2aef7e 100644 --- a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx @@ -24,7 +24,7 @@ const waitForRender = async ( wrapper: ReactWrapper, condition: (wrapper: ReactWrapper) => boolean ) => { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const interval = setInterval(async () => { await Promise.resolve(); wrapper.update(); diff --git a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts index 71ef6496ef6c..de0c915d0c14 100644 --- a/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts +++ b/x-pack/plugins/security/public/session/unauthorized_response_http_interceptor.test.ts @@ -34,7 +34,7 @@ afterEach(() => { it(`logs out 401 responses`, async () => { const http = setupHttp('/foo'); const sessionExpired = new SessionExpired(`${http.basePath}/logout`, tenant); - const logoutPromise = new Promise((resolve) => { + const logoutPromise = new Promise((resolve) => { jest.spyOn(sessionExpired, 'logout').mockImplementation(() => resolve()); }); const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, http.anonymousPaths); diff --git a/x-pack/plugins/security_solution/public/common/utils/clone_http_fetch_query.ts b/x-pack/plugins/security_solution/public/common/utils/clone_http_fetch_query.ts index 90b81df8bc21..809542c6b2fa 100644 --- a/x-pack/plugins/security_solution/public/common/utils/clone_http_fetch_query.ts +++ b/x-pack/plugins/security_solution/public/common/utils/clone_http_fetch_query.ts @@ -11,7 +11,7 @@ export function cloneHttpFetchQuery(query: Immutable): HttpFetch const clone: HttpFetchQuery = {}; for (const [key, value] of Object.entries(query)) { if (Array.isArray(value)) { - clone[key] = [...value]; + clone[key] = [...value] as string[] | number[] | boolean[]; } else { // Array.isArray is not removing ImmutableArray from the union. clone[key] = value as string | number | boolean; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx index b631d37140a3..564b382b7b29 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.test.tsx @@ -40,7 +40,7 @@ describe('AllRulesTable Columns', () => { test('duplicate rule onClick should call refetch after the rule is duplicated', async () => { (duplicateRulesAction as jest.Mock).mockImplementation( () => - new Promise((resolve) => + new Promise((resolve) => setTimeout(() => { results.push('duplicateRulesAction'); resolve(); @@ -62,7 +62,7 @@ describe('AllRulesTable Columns', () => { test('delete rule onClick should call refetch after the rule is deleted', async () => { (deleteRulesAction as jest.Mock).mockImplementation( () => - new Promise((resolve) => + new Promise((resolve) => setTimeout(() => { results.push('deleteRulesAction'); resolve(); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts index bb868418e7f0..5265cee2e597 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts @@ -136,8 +136,8 @@ export const getCurrentResourceError = ( }; export const isOutdatedResourceState = ( - state: AsyncResourceState, - isFresh: (data: Data) => boolean + state: Immutable>, + isFresh: (data: Immutable) => boolean ): boolean => isUninitialisedResourceState(state) || (isLoadedResourceState(state) && !isFresh(state.data)) || diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index 589fbac03a7e..e7c21e1a9976 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -35,7 +35,7 @@ export const needsRefreshOfListData = (state: Immutable { return ( data.pageIndex === location.page_index && diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index 2a538620dce0..7c0f4b7969aa 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -202,7 +202,7 @@ export class Simulator { while (timeoutCount < 10) { timeoutCount++; yield mapper(); - await new Promise((resolve) => { + await new Promise((resolve) => { setTimeout(() => { this.forceAutoSizerOpen(); this.wrapper.update(); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index 35cf2c36d662..4e49617b6c8b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -66,7 +66,7 @@ describe('useCamera on an unpainted element', () => { while (timeoutCount < 10) { timeoutCount++; yield mapper(); - await new Promise((resolve) => { + await new Promise((resolve) => { setTimeout(() => { wrapper.update(); resolve(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx index 1106ee63a03c..ef36d78e51a7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx @@ -23,6 +23,7 @@ export const Duration = React.memo<{ }>(({ contextId, eventId, fieldName, value }) => ( (({ contextId, eventId, fieldName, value }) => ( { [ { id: `id-exists`, - name, + name: 'name', enabled: true, excluded: false, kqlQuery: '', diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.ts index b5aa24336b2d..3f7cf3f97603 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.ts @@ -48,8 +48,8 @@ export const checkTimelinesStatus = async ( readStream, (timelinesFromFileSystem: T) => { if (Array.isArray(timelinesFromFileSystem)) { - const parsedTimelinesFromFileSystem = timelinesFromFileSystem.map((t: string) => - JSON.parse(t) + const parsedTimelinesFromFileSystem = (timelinesFromFileSystem as readonly string[]).map( + (t) => JSON.parse(t) ); const prepackagedTimelines = timeline.timeline ?? []; const timelinesToInstall = getTimelinesToInstall( diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx index 2d7a56f33be8..99a9713521c5 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx @@ -37,6 +37,7 @@ export const HDFSSettings: React.FunctionComponent = ({ settingErrors, }) => { const { + name, settings: { delegateType, uri, diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_table/repository_table.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_table/repository_table.tsx index 4ccec36fba04..335828a9856d 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_table/repository_table.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_table/repository_table.tsx @@ -249,7 +249,11 @@ export const RepositoryTable: React.FunctionComponent = ({ , { const task3 = createTask(); const task4 = createTask(); - return new Promise((resolve) => { + return new Promise((resolve) => { Promise.all([bufferedUpdate(task1), bufferedUpdate(task2)]).then((_) => { expect(bulkUpdate).toHaveBeenCalledTimes(1); expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); @@ -146,7 +146,7 @@ describe('Bulk Operation Buffer', () => { expect(bulkUpdate).toHaveBeenCalledTimes(1); expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); - return new Promise((resolve) => { + return new Promise((resolve) => { const futureUpdates = Promise.all([bufferedUpdate(task3), bufferedUpdate(task4)]); setTimeout(() => { diff --git a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts index f97861901b5b..6f3dcb33d5bf 100644 --- a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts @@ -36,7 +36,7 @@ describe('Configuration Statistics Aggregator', () => { pollIntervalConfiguration$: new Subject(), }; - return new Promise(async (resolve, reject) => { + return new Promise(async (resolve, reject) => { createConfigurationAggregator(configuration, managedConfig) .pipe(take(3), bufferCount(3)) .subscribe(([initial, updatedWorkers, updatedInterval]) => { diff --git a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts index 8479def5deee..b8502dee9a8e 100644 --- a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts @@ -36,7 +36,7 @@ describe('createMonitoringStatsStream', () => { }; it('returns the initial config used to configure Task Manager', async () => { - return new Promise((resolve) => { + return new Promise((resolve) => { createMonitoringStatsStream(of(), configuration) .pipe(take(1)) .subscribe((firstValue) => { @@ -49,7 +49,7 @@ describe('createMonitoringStatsStream', () => { it('incrementally updates the stats returned by the endpoint', async () => { const aggregatedStats$ = new Subject(); - return new Promise((resolve) => { + return new Promise((resolve) => { createMonitoringStatsStream(aggregatedStats$, configuration) .pipe(take(3), bufferCount(3)) .subscribe(([initialValue, secondValue, thirdValue]) => { diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts index 6ab866b6167a..538acd51bb79 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts @@ -60,7 +60,7 @@ describe('Task Run Statistics', () => { }); } - return new Promise((resolve) => { + return new Promise((resolve) => { taskRunAggregator .pipe( // skip initial stat which is just initialized data which @@ -128,7 +128,7 @@ describe('Task Run Statistics', () => { } } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { taskRunAggregator .pipe( // skip initial stat which is just initialized data which @@ -224,7 +224,7 @@ describe('Task Run Statistics', () => { runningAverageWindowSize ); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { taskRunAggregator .pipe( // skip initial stat which is just initialized data which @@ -303,7 +303,7 @@ describe('Task Run Statistics', () => { runningAverageWindowSize ); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { taskRunAggregator .pipe( // skip initial stat which is just initialized data which @@ -394,7 +394,7 @@ describe('Task Run Statistics', () => { runningAverageWindowSize ); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { taskRunAggregator .pipe( // skip initial stat which is just initialized data which diff --git a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts index 3470ee4d7648..21c9f429814c 100644 --- a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts @@ -86,7 +86,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe(() => { expect(taskStore.aggregate).toHaveBeenCalledWith({ aggs: { @@ -253,7 +253,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe((result) => { expect(result.key).toEqual('workload'); expect(result.value).toMatchObject({ @@ -283,7 +283,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise(async (resolve) => { + return new Promise(async (resolve) => { workloadAggregator.pipe(first()).subscribe((result) => { expect(result.key).toEqual('workload'); expect(result.value).toMatchObject({ @@ -319,7 +319,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe((result) => { expect(result.key).toEqual('workload'); expect(result.value).toMatchObject({ @@ -342,7 +342,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe((result) => { expect(result.key).toEqual('workload'); expect(result.value).toMatchObject({ @@ -370,7 +370,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe(() => { expect(taskStore.aggregate.mock.calls[0][0]).toMatchObject({ aggs: { @@ -408,7 +408,7 @@ describe('Workload Statistics Aggregator', () => { loggingSystemMock.create().get() ); - return new Promise((resolve) => { + return new Promise((resolve) => { workloadAggregator.pipe(first()).subscribe((result) => { expect(taskStore.aggregate.mock.calls[0][0]).toMatchObject({ aggs: { @@ -453,7 +453,7 @@ describe('Workload Statistics Aggregator', () => { const logger = loggingSystemMock.create().get(); const workloadAggregator = createWorkloadAggregator(taskStore, of(true), 10, 3000, logger); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { workloadAggregator.pipe(take(2), bufferCount(2)).subscribe((results) => { expect(results[0].key).toEqual('workload'); expect(results[0].value).toMatchObject({ @@ -491,7 +491,7 @@ describe('Workload Statistics Aggregator', () => { logger ); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { let errorWasThrowAt = 0; taskStore.aggregate.mockImplementation(async () => { if (errorWasThrowAt === 0) { diff --git a/x-pack/plugins/task_manager/server/polling/observable_monitor.test.ts b/x-pack/plugins/task_manager/server/polling/observable_monitor.test.ts index 0b7bbdfb623e..8fe2d59eee12 100644 --- a/x-pack/plugins/task_manager/server/polling/observable_monitor.test.ts +++ b/x-pack/plugins/task_manager/server/polling/observable_monitor.test.ts @@ -25,7 +25,7 @@ describe('Poll Monitor', () => { expect(instantiator).not.toHaveBeenCalled(); - return new Promise((resolve) => { + return new Promise((resolve) => { const next = jest.fn(); monitoredObservable.pipe(take(3)).subscribe({ next, @@ -45,7 +45,7 @@ describe('Poll Monitor', () => { const instantiator = jest.fn(() => interval(100)); const monitoredObservable = createObservableMonitor(instantiator, { heartbeatInterval }); - return new Promise((resolve) => { + return new Promise((resolve) => { const next = jest.fn(); monitoredObservable.pipe(take(3)).subscribe({ next, @@ -79,7 +79,7 @@ describe('Poll Monitor', () => { const onError = jest.fn(); const monitoredObservable = createObservableMonitor(instantiator, { onError }); - return new Promise((resolve) => { + return new Promise((resolve) => { const next = jest.fn(); const error = jest.fn(); monitoredObservable @@ -135,7 +135,7 @@ describe('Poll Monitor', () => { inactivityTimeout: 500, }); - return new Promise((resolve) => { + return new Promise((resolve) => { const next = jest.fn(); const error = jest.fn(); monitoredObservable diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 06d33da8f1f5..c9048eda45fe 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -689,7 +689,7 @@ export const waitFor = async ( maxTimeout: number = 10000, timeoutWait: number = 10 ): Promise => { - await new Promise(async (resolve, reject) => { + await new Promise(async (resolve, reject) => { let found = false; let numberOfTries = 0; while (!found && numberOfTries < Math.floor(maxTimeout / timeoutWait)) { diff --git a/x-pack/test/functional_enterprise_search/services/app_search_client.ts b/x-pack/test/functional_enterprise_search/services/app_search_client.ts index fbd15b83f97e..f85ecd53903a 100644 --- a/x-pack/test/functional_enterprise_search/services/app_search_client.ts +++ b/x-pack/test/functional_enterprise_search/services/app_search_client.ts @@ -104,7 +104,7 @@ const search = async (engineName: string): Promise => { // Since the App Search API does not issue document receipts, the only way to tell whether or not documents // are fully indexed is to poll the search endpoint. export const waitForIndexedDocs = (engineName: string) => { - return new Promise(async function (resolve) { + return new Promise(async function (resolve) { let isReady = false; while (!isReady) { const response = await search(engineName); diff --git a/x-pack/test/lists_api_integration/utils.ts b/x-pack/test/lists_api_integration/utils.ts index 224048e868d7..53472b459b8a 100644 --- a/x-pack/test/lists_api_integration/utils.ts +++ b/x-pack/test/lists_api_integration/utils.ts @@ -115,7 +115,7 @@ export const waitFor = async ( maxTimeout: number = 5000, timeoutWait: number = 10 ) => { - await new Promise(async (resolve, reject) => { + await new Promise(async (resolve, reject) => { let found = false; let numberOfTries = 0; while (!found && numberOfTries < Math.floor(maxTimeout / timeoutWait)) { diff --git a/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts b/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts index ced9598809e1..4552df4cf2b3 100644 --- a/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts +++ b/x-pack/test/security_api_integration/tests/oidc/implicit_flow/oidc_auth.ts @@ -46,7 +46,7 @@ export default function ({ getService }: FtrProviderContext) { // JSDOM doesn't support changing of `window.location` and throws an exception if script // tries to do that and we have to workaround this behaviour. We also need to wait until our // script is loaded and executed, __isScriptExecuted__ is used exactly for that. - (window as Record).__isScriptExecuted__ = new Promise((resolve) => { + (window as Record).__isScriptExecuted__ = new Promise((resolve) => { Object.defineProperty(window, 'location', { value: { href: diff --git a/x-pack/test/security_api_integration/tests/token/header.ts b/x-pack/test/security_api_integration/tests/token/header.ts index 2150d7a6269b..53b50286cc6c 100644 --- a/x-pack/test/security_api_integration/tests/token/header.ts +++ b/x-pack/test/security_api_integration/tests/token/header.ts @@ -66,7 +66,7 @@ export default function ({ getService }: FtrProviderContext) { // Access token expiration is set to 15s for API integration tests. // Let's wait for 20s to make sure token expires. - await new Promise((resolve) => setTimeout(() => resolve(), 20000)); + await new Promise((resolve) => setTimeout(resolve, 20000)); await supertest .get('/internal/security/me') diff --git a/x-pack/test/security_api_integration/tests/token/session.ts b/x-pack/test/security_api_integration/tests/token/session.ts index 30e004a0fff3..daee8264bd0b 100644 --- a/x-pack/test/security_api_integration/tests/token/session.ts +++ b/x-pack/test/security_api_integration/tests/token/session.ts @@ -8,7 +8,7 @@ import request, { Cookie } from 'request'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; -const delay = (ms: number) => new Promise((resolve) => setTimeout(() => resolve(), ms)); +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); diff --git a/yarn.lock b/yarn.lock index 037606b60791..d20c7f78c979 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27649,10 +27649,10 @@ typescript-tuple@^2.2.1: dependencies: typescript-compare "^0.0.2" -typescript@4.0.2, typescript@^3.0.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.4.5, typescript@~3.7.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2" - integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ== +typescript@4.1.2, typescript@^3.0.3, typescript@^3.2.2, typescript@^3.3.3333, typescript@^3.4.5, typescript@~3.7.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.2.tgz#6369ef22516fe5e10304aae5a5c4862db55380e9" + integrity sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ== ua-parser-js@^0.7.18: version "0.7.22" From d80e8ca2eee7ac5866336a6a07887169b3577d6f Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 24 Nov 2020 15:06:21 +0000 Subject: [PATCH 24/89] [Security Solution] Fix incorrect time for dns histogram (#83532) * getSuitableUnit * update dns histogram query * update dns query * update dns histogram query * fix type error * fix lint error * remove unused comments * fix histogram query size * revert change * fix unit test * fix dns request options * clean up * cleanup types * fix dependency * review * review * revert * restore docValueFields * fix unit test * cleanup * restore docValueFields for dns histogram * review * review * lint Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../matrix_histogram/index.ts | 1 + .../common/components/charts/barchart.tsx | 7 +- .../components/matrix_histogram/index.tsx | 4 + .../components/matrix_histogram/types.ts | 3 + .../containers/matrix_histogram/index.ts | 20 +- .../containers/network_dns/histogram.ts | 65 -- .../network/containers/network_dns/index.tsx | 25 +- .../pages/navigation/dns_query_tab_body.tsx | 15 +- .../pages/navigation/network_routes.tsx | 3 +- .../public/network/pages/navigation/types.ts | 6 +- .../public/network/pages/network.tsx | 1 + .../server/lib/hosts/query.hosts.dsl.ts | 2 +- .../hosts/query.last_first_seen_host.dsl.ts | 2 +- .../factory/hosts/all/__mocks__/index.ts | 56 +- .../factory/hosts/all/query.all_hosts.dsl.ts | 2 +- .../hosts/authentications/__mocks__/index.ts | 2 + .../hosts/authentications/dsl/query.dsl.ts | 2 +- .../hosts/last_first_seen/__mocks__/index.ts | 28 +- .../query.last_first_seen_host.dsl.ts | 2 +- .../matrix_histogram/__mocks__/index.ts | 929 ++++++++++++++---- .../matrix_histogram/dns/__mocks__/index.ts | 53 +- .../factory/matrix_histogram/dns/helpers.ts | 7 +- .../matrix_histogram/dns/index.test.ts | 4 +- .../factory/matrix_histogram/dns/index.ts | 4 +- .../dns/query.dns_histogram.dsl.ts | 121 ++- .../network/details/__mocks__/index.ts | 97 +- .../details/query.details_network.dsl.ts | 2 +- .../network/dns/query.dns_network.dsl.ts | 4 +- .../factory/network/http/__mocks__/index.ts | 6 +- .../events/all/query.events_all.dsl.ts | 2 +- .../query.events_last_event_time.dsl.ts | 6 +- 31 files changed, 1103 insertions(+), 378 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/network/containers/network_dns/histogram.ts diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/index.ts index 84a5d868c34a..750cda54b0c2 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/index.ts @@ -37,6 +37,7 @@ export interface MatrixHistogramRequestOptions extends RequestBasicOptions { stackByField: string; threshold?: { field: string | undefined; value: number } | undefined; inspect?: Maybe; + isPtrIncluded?: boolean; } export interface MatrixHistogramStrategyResponse extends IEsSearchResponse { diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx index fb1ed956dfc5..5a50442f8dd5 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx @@ -47,6 +47,9 @@ const checkIfAnyValidSeriesExist = ( !checkIfAllValuesAreZero(data) && data.some(checkIfAllTheDataInTheSeriesAreValid); +const yAccessors = ['y']; +const splitSeriesAccessors = ['g']; + // Bar chart rotation: https://ela.st/chart-rotations export const BarChartBaseComponent = ({ data, @@ -86,9 +89,9 @@ export const BarChartBaseComponent = ({ xScaleType={getOr(ScaleType.Linear, 'configs.series.xScaleType', chartConfigs)} yScaleType={getOr(ScaleType.Linear, 'configs.series.yScaleType', chartConfigs)} xAccessor="x" - yAccessors={['y']} + yAccessors={yAccessors} timeZone={timeZone} - splitSeriesAccessors={['g']} + splitSeriesAccessors={splitSeriesAccessors} data={series.value!} stackAccessors={get('configs.series.stackAccessors', chartConfigs)} color={series.color ? series.color : undefined} diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index e7d7e60a3c40..5f567508a401 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -64,6 +64,7 @@ const HistogramPanel = styled(Panel)<{ height?: number }>` export const MatrixHistogramComponent: React.FC = ({ chartHeight, defaultStackByOption, + docValueFields, endDate, errorMessage, filterQuery, @@ -72,6 +73,7 @@ export const MatrixHistogramComponent: React.FC = hideHistogramIfEmpty = false, id, indexNames, + isPtrIncluded, legendPosition, mapping, panelHeight = DEFAULT_PANEL_HEIGHT, @@ -138,6 +140,8 @@ export const MatrixHistogramComponent: React.FC = indexNames, startDate, stackByField: selectedStackByOption.value, + isPtrIncluded, + docValueFields, }); const titleWithStackByField = useMemo( diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts index 327c2fa64997..713c5d4738fd 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts @@ -12,6 +12,7 @@ import { InputsModelId } from '../../store/inputs/constants'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { UpdateDateRange } from '../charts/common'; import { GlobalTimeArgs } from '../../containers/use_global_time'; +import { DocValueFields } from '../../../../common/search_strategy'; export type MatrixHistogramMappingTypes = Record< string, @@ -57,6 +58,7 @@ interface MatrixHistogramBasicProps { } export interface MatrixHistogramQueryProps { + docValueFields?: DocValueFields[]; endDate: string; errorMessage: string; indexNames: string[]; @@ -72,6 +74,7 @@ export interface MatrixHistogramQueryProps { histogramType: MatrixHistogramType; threshold?: { field: string | undefined; value: number } | undefined; skip?: boolean; + isPtrIncluded?: boolean; } export interface MatrixHistogramProps extends MatrixHistogramBasicProps { diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts index 3ef6d78d651a..df553f509a0e 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts @@ -5,7 +5,7 @@ */ import deepEqual from 'fast-deep-equal'; -import { getOr, noop } from 'lodash/fp'; +import { getOr, isEmpty, noop } from 'lodash/fp'; import { useCallback, useEffect, useRef, useState } from 'react'; import { MatrixHistogramQueryProps } from '../../components/matrix_histogram/types'; @@ -43,11 +43,13 @@ export interface UseMatrixHistogramArgs { } export const useMatrixHistogram = ({ + docValueFields, endDate, errorMessage, filterQuery, histogramType, indexNames, + isPtrIncluded, stackByField, startDate, threshold, @@ -76,6 +78,8 @@ export const useMatrixHistogram = ({ }, stackByField, threshold, + ...(isPtrIncluded != null ? { isPtrIncluded } : {}), + ...(!isEmpty(docValueFields) ? { docValueFields } : {}), }); const [matrixHistogramResponse, setMatrixHistogramResponse] = useState({ @@ -167,13 +171,25 @@ export const useMatrixHistogram = ({ }, stackByField, threshold, + ...(isPtrIncluded != null ? { isPtrIncluded } : {}), + ...(!isEmpty(docValueFields) ? { docValueFields } : {}), }; if (!deepEqual(prevRequest, myRequest)) { return myRequest; } return prevRequest; }); - }, [indexNames, endDate, filterQuery, startDate, stackByField, histogramType, threshold]); + }, [ + indexNames, + endDate, + filterQuery, + startDate, + stackByField, + histogramType, + threshold, + isPtrIncluded, + docValueFields, + ]); useEffect(() => { if (!skip) { diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/histogram.ts b/x-pack/plugins/security_solution/public/network/containers/network_dns/histogram.ts deleted file mode 100644 index dce0c3bd2b30..000000000000 --- a/x-pack/plugins/security_solution/public/network/containers/network_dns/histogram.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { connect } from 'react-redux'; -import { compose } from 'redux'; -import { DocumentNode } from 'graphql'; -import { ScaleType } from '@elastic/charts'; - -import { MatrixHistogram } from '../../../common/components/matrix_histogram'; -import { - MatrixHistogramOption, - GetSubTitle, -} from '../../../common/components/matrix_histogram/types'; -import { UpdateDateRange } from '../../../common/components/charts/common'; -import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; -import { withKibana } from '../../../common/lib/kibana'; -import { QueryTemplatePaginatedProps } from '../../../common/containers/query_template_paginated'; -import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../../common/store/constants'; -import { networkModel, networkSelectors } from '../../store'; -import { State, inputsSelectors } from '../../../common/store'; - -export const HISTOGRAM_ID = 'networkDnsHistogramQuery'; - -interface DnsHistogramOwnProps extends QueryTemplatePaginatedProps { - dataKey: string | string[]; - defaultStackByOption: MatrixHistogramOption; - errorMessage: string; - isDnsHistogram?: boolean; - query: DocumentNode; - scaleType: ScaleType; - setQuery: GlobalTimeArgs['setQuery']; - showLegend?: boolean; - stackByOptions: MatrixHistogramOption[]; - subtitle?: string | GetSubTitle; - title: string; - type: networkModel.NetworkType; - updateDateRange: UpdateDateRange; - yTickFormatter?: (value: number) => string; -} - -const makeMapHistogramStateToProps = () => { - const getNetworkDnsSelector = networkSelectors.dnsSelector(); - const getQuery = inputsSelectors.globalQueryByIdSelector(); - const mapStateToProps = (state: State, { id = HISTOGRAM_ID }: DnsHistogramOwnProps) => { - const { isInspected } = getQuery(state, id); - return { - ...getNetworkDnsSelector(state), - activePage: DEFAULT_TABLE_ACTIVE_PAGE, - limit: DEFAULT_TABLE_LIMIT, - isInspected, - id, - }; - }; - - return mapStateToProps; -}; - -export const NetworkDnsHistogramQuery = compose>( - connect(makeMapHistogramStateToProps), - withKibana -)(MatrixHistogram); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx index 108bfa0c9df6..aab90702de33 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx @@ -13,23 +13,23 @@ import { inputsModel } from '../../../common/store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { useKibana } from '../../../common/lib/kibana'; import { createFilter } from '../../../common/containers/helpers'; -import { NetworkDnsEdges, PageInfoPaginated } from '../../../../common/search_strategy'; import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; import { networkModel, networkSelectors } from '../../store'; import { + DocValueFields, NetworkQueries, NetworkDnsRequestOptions, NetworkDnsStrategyResponse, MatrixOverOrdinalHistogramData, -} from '../../../../common/search_strategy/security_solution/network'; + NetworkDnsEdges, + PageInfoPaginated, +} from '../../../../common/search_strategy'; import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; import { AbortError } from '../../../../../../../src/plugins/kibana_utils/common'; import * as i18n from './translations'; import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; -export * from './histogram'; - const ID = 'networkDnsQuery'; export interface NetworkDnsArgs { @@ -47,6 +47,7 @@ export interface NetworkDnsArgs { interface UseNetworkDns { id?: string; + docValueFields: DocValueFields[]; indexNames: string[]; type: networkModel.NetworkType; filterQuery?: ESTermQuery | string; @@ -56,6 +57,7 @@ interface UseNetworkDns { } export const useNetworkDns = ({ + docValueFields, endDate, filterQuery, indexNames, @@ -74,6 +76,7 @@ export const useNetworkDns = ({ !skip ? { defaultIndex: indexNames, + docValueFields: docValueFields ?? [], factoryQueryType: NetworkQueries.dns, filterQuery: createFilter(filterQuery), isPtrIncluded, @@ -190,6 +193,7 @@ export const useNetworkDns = ({ const myRequest = { ...(prevRequest ?? {}), defaultIndex: indexNames, + docValueFields: docValueFields ?? [], isPtrIncluded, factoryQueryType: NetworkQueries.dns, filterQuery: createFilter(filterQuery), @@ -206,7 +210,18 @@ export const useNetworkDns = ({ } return prevRequest; }); - }, [activePage, indexNames, endDate, filterQuery, limit, startDate, sort, skip, isPtrIncluded]); + }, [ + activePage, + indexNames, + endDate, + filterQuery, + limit, + startDate, + sort, + skip, + isPtrIncluded, + docValueFields, + ]); useEffect(() => { networkDnsSearch(networkDnsRequest); diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx index a8bae2509e0d..8d850a926f09 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useCallback, useMemo } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkDnsTable } from '../../components/network_dns_table'; -import { useNetworkDns, HISTOGRAM_ID } from '../../containers/network_dns'; +import { useNetworkDns } from '../../containers/network_dns'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkComponentQueryProps } from './types'; @@ -20,6 +20,10 @@ import { import * as i18n from '../translations'; import { MatrixHistogram } from '../../../common/components/matrix_histogram'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; +import { networkSelectors } from '../../store'; +import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; + +const HISTOGRAM_ID = 'networkDnsHistogramQuery'; const NetworkDnsTableManage = manageQuery(NetworkDnsTable); @@ -43,6 +47,7 @@ export const histogramConfigs: Omit = { const DnsQueryTabBodyComponent: React.FC = ({ deleteQuery, + docValueFields, endDate, filterQuery, indexNames, @@ -51,6 +56,9 @@ const DnsQueryTabBodyComponent: React.FC = ({ setQuery, type, }) => { + const getNetworkDnsSelector = networkSelectors.dnsSelector(); + const { isPtrIncluded } = useShallowEqualSelector(getNetworkDnsSelector); + useEffect(() => { return () => { if (deleteQuery) { @@ -63,6 +71,7 @@ const DnsQueryTabBodyComponent: React.FC = ({ loading, { totalCount, networkDns, pageInfo, loadPage, id, inspect, isInspected, refetch }, ] = useNetworkDns({ + docValueFields: docValueFields ?? [], endDate, filterQuery, indexNames, @@ -87,9 +96,11 @@ const DnsQueryTabBodyComponent: React.FC = ({ return ( <> ( ({ networkPagePath, + docValueFields, type, to, filterQuery, @@ -107,7 +108,7 @@ export const NetworkRoutes = React.memo( return ( - + <> diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/types.ts b/x-pack/plugins/security_solution/public/network/pages/navigation/types.ts index ed04fd01a7b8..ef8cc4079e9a 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/types.ts @@ -14,6 +14,7 @@ import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SetAbsoluteRangeDatePicker } from '../types'; import { NarrowDateRange } from '../../../common/components/ml/types'; +import { DocValueFields } from '../../../common/containers/source'; interface QueryTabBodyProps extends Pick { skip: boolean; @@ -25,7 +26,9 @@ interface QueryTabBodyProps extends Pick( = { isPartial: false, isRunning: false, rawResponse: { - took: 150, + took: 36, timed_out: false, - _shards: { total: 21, successful: 21, skipped: 0, failed: 0 }, - hits: { total: 0, max_score: 0, hits: [] }, + _shards: { + total: 55, + successful: 55, + skipped: 38, + failed: 0, + }, + hits: { + max_score: 0, + hits: [], + total: 0, + }, aggregations: { - NetworkDns: { + dns_count: { + value: 3, + }, + dns_name_query_count: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, buckets: [ { - key_as_string: '2020-09-08T15:00:00.000Z', - key: 1599577200000, - doc_count: 7083, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T15:45:00.000Z', - key: 1599579900000, - doc_count: 146148, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T16:30:00.000Z', - key: 1599582600000, - doc_count: 65025, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T17:15:00.000Z', - key: 1599585300000, - doc_count: 62317, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T18:00:00.000Z', - key: 1599588000000, - doc_count: 58223, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T18:45:00.000Z', - key: 1599590700000, - doc_count: 55712, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T19:30:00.000Z', - key: 1599593400000, - doc_count: 55328, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T20:15:00.000Z', - key: 1599596100000, - doc_count: 63878, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T21:00:00.000Z', - key: 1599598800000, - doc_count: 54151, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T21:45:00.000Z', - key: 1599601500000, - doc_count: 55170, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T22:30:00.000Z', - key: 1599604200000, - doc_count: 43115, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-08T23:15:00.000Z', - key: 1599606900000, - doc_count: 52204, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T00:00:00.000Z', - key: 1599609600000, - doc_count: 43609, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T00:45:00.000Z', - key: 1599612300000, - doc_count: 44825, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T01:30:00.000Z', - key: 1599615000000, - doc_count: 52374, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T02:15:00.000Z', - key: 1599617700000, - doc_count: 44667, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T03:00:00.000Z', - key: 1599620400000, - doc_count: 45231, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T03:45:00.000Z', - key: 1599623100000, - doc_count: 42871, - dns: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, + key: 'google.com', + doc_count: 1, + unique_domains: { + value: 1, + }, + dns_question_name: { buckets: [ - { key: 'google.com', doc_count: 1, orderAgg: { value: 1 } }, - { key: 'google.internal', doc_count: 1, orderAgg: { value: 1 } }, + { + key_as_string: '2020-11-12T01:13:31.395Z', + key: 1605143611395, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:21:48.492Z', + key: 1605144108492, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:30:05.589Z', + key: 1605144605589, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:38:22.686Z', + key: 1605145102686, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:46:39.783Z', + key: 1605145599783, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:54:56.880Z', + key: 1605146096880, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:03:13.977Z', + key: 1605146593977, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:11:31.074Z', + key: 1605147091074, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:19:48.171Z', + key: 1605147588171, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:28:05.268Z', + key: 1605148085268, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:36:22.365Z', + key: 1605148582365, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:44:39.462Z', + key: 1605149079462, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:52:56.559Z', + key: 1605149576559, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:01:13.656Z', + key: 1605150073656, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:09:30.753Z', + key: 1605150570753, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:17:47.850Z', + key: 1605151067850, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:26:04.947Z', + key: 1605151564947, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:34:22.044Z', + key: 1605152062044, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:42:39.141Z', + key: 1605152559141, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:50:56.238Z', + key: 1605153056238, + doc_count: 1, + }, + { + key_as_string: '2020-11-12T03:59:13.335Z', + key: 1605153553335, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:07:30.432Z', + key: 1605154050432, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:15:47.529Z', + key: 1605154547529, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:24:04.626Z', + key: 1605155044626, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:32:21.723Z', + key: 1605155541723, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:40:38.820Z', + key: 1605156038820, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:48:55.917Z', + key: 1605156535917, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:57:13.014Z', + key: 1605157033014, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:05:30.111Z', + key: 1605157530111, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:13:47.208Z', + key: 1605158027208, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:22:04.305Z', + key: 1605158524305, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:30:21.402Z', + key: 1605159021402, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:38:38.499Z', + key: 1605159518499, + doc_count: 0, + }, ], }, + dns_bytes_in: { + value: 0, + }, + dns_bytes_out: { + value: 0, + }, }, { - key_as_string: '2020-09-09T04:30:00.000Z', - key: 1599625800000, - doc_count: 41327, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T05:15:00.000Z', - key: 1599628500000, - doc_count: 39860, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T06:00:00.000Z', - key: 1599631200000, - doc_count: 44061, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T06:45:00.000Z', - key: 1599633900000, - doc_count: 39193, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T07:30:00.000Z', - key: 1599636600000, - doc_count: 40909, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T08:15:00.000Z', - key: 1599639300000, - doc_count: 43293, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T09:00:00.000Z', - key: 1599642000000, - doc_count: 47640, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T09:45:00.000Z', - key: 1599644700000, - doc_count: 48605, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T10:30:00.000Z', - key: 1599647400000, - doc_count: 42072, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T11:15:00.000Z', - key: 1599650100000, - doc_count: 46398, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T12:00:00.000Z', - key: 1599652800000, - doc_count: 49378, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T12:45:00.000Z', - key: 1599655500000, - doc_count: 51171, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T13:30:00.000Z', - key: 1599658200000, - doc_count: 57911, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - { - key_as_string: '2020-09-09T14:15:00.000Z', - key: 1599660900000, - doc_count: 58909, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + key: 'google.internal', + doc_count: 1, + unique_domains: { + value: 1, + }, + dns_question_name: { + buckets: [ + { + key_as_string: '2020-11-12T01:13:31.395Z', + key: 1605143611395, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:21:48.492Z', + key: 1605144108492, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:30:05.589Z', + key: 1605144605589, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:38:22.686Z', + key: 1605145102686, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:46:39.783Z', + key: 1605145599783, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:54:56.880Z', + key: 1605146096880, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:03:13.977Z', + key: 1605146593977, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:11:31.074Z', + key: 1605147091074, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:19:48.171Z', + key: 1605147588171, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:28:05.268Z', + key: 1605148085268, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:36:22.365Z', + key: 1605148582365, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:44:39.462Z', + key: 1605149079462, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:52:56.559Z', + key: 1605149576559, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:01:13.656Z', + key: 1605150073656, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:09:30.753Z', + key: 1605150570753, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:17:47.850Z', + key: 1605151067850, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:26:04.947Z', + key: 1605151564947, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:34:22.044Z', + key: 1605152062044, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:42:39.141Z', + key: 1605152559141, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:50:56.238Z', + key: 1605153056238, + doc_count: 1, + }, + { + key_as_string: '2020-11-12T03:59:13.335Z', + key: 1605153553335, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:07:30.432Z', + key: 1605154050432, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:15:47.529Z', + key: 1605154547529, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:24:04.626Z', + key: 1605155044626, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:32:21.723Z', + key: 1605155541723, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:40:38.820Z', + key: 1605156038820, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:48:55.917Z', + key: 1605156535917, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:57:13.014Z', + key: 1605157033014, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:05:30.111Z', + key: 1605157530111, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:13:47.208Z', + key: 1605158027208, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:22:04.305Z', + key: 1605158524305, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:30:21.402Z', + key: 1605159021402, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:38:38.499Z', + key: 1605159518499, + doc_count: 0, + }, + ], + }, + dns_bytes_in: { + value: 0, + }, + dns_bytes_out: { + value: 0, + }, }, { - key_as_string: '2020-09-09T15:00:00.000Z', - key: 1599663600000, - doc_count: 62358, - dns: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + key: 'windows.net', + doc_count: 1, + unique_domains: { + value: 1, + }, + dns_question_name: { + buckets: [ + { + key_as_string: '2020-11-12T01:13:31.395Z', + key: 1605143611395, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:21:48.492Z', + key: 1605144108492, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:30:05.589Z', + key: 1605144605589, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:38:22.686Z', + key: 1605145102686, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:46:39.783Z', + key: 1605145599783, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T01:54:56.880Z', + key: 1605146096880, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:03:13.977Z', + key: 1605146593977, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:11:31.074Z', + key: 1605147091074, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:19:48.171Z', + key: 1605147588171, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:28:05.268Z', + key: 1605148085268, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:36:22.365Z', + key: 1605148582365, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:44:39.462Z', + key: 1605149079462, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T02:52:56.559Z', + key: 1605149576559, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:01:13.656Z', + key: 1605150073656, + doc_count: 1, + }, + { + key_as_string: '2020-11-12T03:09:30.753Z', + key: 1605150570753, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:17:47.850Z', + key: 1605151067850, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:26:04.947Z', + key: 1605151564947, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:34:22.044Z', + key: 1605152062044, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:42:39.141Z', + key: 1605152559141, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:50:56.238Z', + key: 1605153056238, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T03:59:13.335Z', + key: 1605153553335, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:07:30.432Z', + key: 1605154050432, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:15:47.529Z', + key: 1605154547529, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:24:04.626Z', + key: 1605155044626, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:32:21.723Z', + key: 1605155541723, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:40:38.820Z', + key: 1605156038820, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:48:55.917Z', + key: 1605156535917, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T04:57:13.014Z', + key: 1605157033014, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:05:30.111Z', + key: 1605157530111, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:13:47.208Z', + key: 1605158027208, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:22:04.305Z', + key: 1605158524305, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:30:21.402Z', + key: 1605159021402, + doc_count: 0, + }, + { + key_as_string: '2020-11-12T05:38:38.499Z', + key: 1605159518499, + doc_count: 0, + }, + ], + }, + dns_bytes_in: { + value: 0, + }, + dns_bytes_out: { + value: 0, + }, }, ], }, @@ -1566,6 +1921,7 @@ export const formattedDnsSearchStrategyResponse: MatrixHistogramStrategyResponse dsl: [ JSON.stringify( { + allowNoIndices: true, index: [ 'apm-*-transaction*', 'auditbeat-*', @@ -1575,20 +1931,50 @@ export const formattedDnsSearchStrategyResponse: MatrixHistogramStrategyResponse 'packetbeat-*', 'winlogbeat-*', ], - allowNoIndices: true, ignoreUnavailable: true, body: { aggregations: { - NetworkDns: { - date_histogram: { field: '@timestamp', fixed_interval: '2700000ms' }, + dns_count: { + cardinality: { + field: 'dns.question.registered_domain', + }, + }, + dns_name_query_count: { + terms: { + field: 'dns.question.registered_domain', + size: 1000000, + }, aggs: { - dns: { - terms: { - field: 'dns.question.registered_domain', - order: { orderAgg: 'desc' }, + dns_question_name: { + date_histogram: { + field: '@timestamp', + fixed_interval: '2700000ms', + min_doc_count: 0, + extended_bounds: { min: 1599579675528, max: 1599666075529 }, + }, + }, + bucket_sort: { + bucket_sort: { + sort: [ + { + unique_domains: { + order: 'desc', + }, + }, + { + _key: { + order: 'asc', + }, + }, + ], + from: 0, size: 10, }, - aggs: { orderAgg: { cardinality: { field: 'dns.question.name' } } }, + }, + unique_domains: { + cardinality: { + field: 'dns.question.name', + }, }, }, }, @@ -1596,7 +1982,18 @@ export const formattedDnsSearchStrategyResponse: MatrixHistogramStrategyResponse query: { bool: { filter: [ - { bool: { must: [], filter: [{ match_all: {} }], should: [], must_not: [] } }, + { + bool: { + must: [], + filter: [ + { + match_all: {}, + }, + ], + should: [], + must_not: [], + }, + }, { range: { '@timestamp': { @@ -1607,11 +2004,20 @@ export const formattedDnsSearchStrategyResponse: MatrixHistogramStrategyResponse }, }, ], + must_not: [ + { + term: { + 'dns.question.type': { + value: 'PTR', + }, + }, + }, + ], }, }, - size: 0, - track_total_hits: true, }, + size: 0, + track_total_hits: false, }, null, 2 @@ -1619,8 +2025,105 @@ export const formattedDnsSearchStrategyResponse: MatrixHistogramStrategyResponse ], }, matrixHistogramData: [ - { x: 1599623100000, y: 1, g: 'google.com' }, - { x: 1599623100000, y: 1, g: 'google.internal' }, + { x: 1605143611395, y: 0, g: 'google.com' }, + { x: 1605144108492, y: 0, g: 'google.com' }, + { x: 1605144605589, y: 0, g: 'google.com' }, + { x: 1605145102686, y: 0, g: 'google.com' }, + { x: 1605145599783, y: 0, g: 'google.com' }, + { x: 1605146096880, y: 0, g: 'google.com' }, + { x: 1605146593977, y: 0, g: 'google.com' }, + { x: 1605147091074, y: 0, g: 'google.com' }, + { x: 1605147588171, y: 0, g: 'google.com' }, + { x: 1605148085268, y: 0, g: 'google.com' }, + { x: 1605148582365, y: 0, g: 'google.com' }, + { x: 1605149079462, y: 0, g: 'google.com' }, + { x: 1605149576559, y: 0, g: 'google.com' }, + { x: 1605150073656, y: 0, g: 'google.com' }, + { x: 1605150570753, y: 0, g: 'google.com' }, + { x: 1605151067850, y: 0, g: 'google.com' }, + { x: 1605151564947, y: 0, g: 'google.com' }, + { x: 1605152062044, y: 0, g: 'google.com' }, + { x: 1605152559141, y: 0, g: 'google.com' }, + { x: 1605153056238, y: 1, g: 'google.com' }, + { x: 1605153553335, y: 0, g: 'google.com' }, + { x: 1605154050432, y: 0, g: 'google.com' }, + { x: 1605154547529, y: 0, g: 'google.com' }, + { x: 1605155044626, y: 0, g: 'google.com' }, + { x: 1605155541723, y: 0, g: 'google.com' }, + { x: 1605156038820, y: 0, g: 'google.com' }, + { x: 1605156535917, y: 0, g: 'google.com' }, + { x: 1605157033014, y: 0, g: 'google.com' }, + { x: 1605157530111, y: 0, g: 'google.com' }, + { x: 1605158027208, y: 0, g: 'google.com' }, + { x: 1605158524305, y: 0, g: 'google.com' }, + { x: 1605159021402, y: 0, g: 'google.com' }, + { x: 1605159518499, y: 0, g: 'google.com' }, + { x: 1605143611395, y: 0, g: 'google.internal' }, + { x: 1605144108492, y: 0, g: 'google.internal' }, + { x: 1605144605589, y: 0, g: 'google.internal' }, + { x: 1605145102686, y: 0, g: 'google.internal' }, + { x: 1605145599783, y: 0, g: 'google.internal' }, + { x: 1605146096880, y: 0, g: 'google.internal' }, + { x: 1605146593977, y: 0, g: 'google.internal' }, + { x: 1605147091074, y: 0, g: 'google.internal' }, + { x: 1605147588171, y: 0, g: 'google.internal' }, + { x: 1605148085268, y: 0, g: 'google.internal' }, + { x: 1605148582365, y: 0, g: 'google.internal' }, + { x: 1605149079462, y: 0, g: 'google.internal' }, + { x: 1605149576559, y: 0, g: 'google.internal' }, + { x: 1605150073656, y: 0, g: 'google.internal' }, + { x: 1605150570753, y: 0, g: 'google.internal' }, + { x: 1605151067850, y: 0, g: 'google.internal' }, + { x: 1605151564947, y: 0, g: 'google.internal' }, + { x: 1605152062044, y: 0, g: 'google.internal' }, + { x: 1605152559141, y: 0, g: 'google.internal' }, + { x: 1605153056238, y: 1, g: 'google.internal' }, + { x: 1605153553335, y: 0, g: 'google.internal' }, + { x: 1605154050432, y: 0, g: 'google.internal' }, + { x: 1605154547529, y: 0, g: 'google.internal' }, + { x: 1605155044626, y: 0, g: 'google.internal' }, + { x: 1605155541723, y: 0, g: 'google.internal' }, + { x: 1605156038820, y: 0, g: 'google.internal' }, + { x: 1605156535917, y: 0, g: 'google.internal' }, + { x: 1605157033014, y: 0, g: 'google.internal' }, + { x: 1605157530111, y: 0, g: 'google.internal' }, + { x: 1605158027208, y: 0, g: 'google.internal' }, + { x: 1605158524305, y: 0, g: 'google.internal' }, + { x: 1605159021402, y: 0, g: 'google.internal' }, + { x: 1605159518499, y: 0, g: 'google.internal' }, + { x: 1605143611395, y: 0, g: 'windows.net' }, + { x: 1605144108492, y: 0, g: 'windows.net' }, + { x: 1605144605589, y: 0, g: 'windows.net' }, + { x: 1605145102686, y: 0, g: 'windows.net' }, + { x: 1605145599783, y: 0, g: 'windows.net' }, + { x: 1605146096880, y: 0, g: 'windows.net' }, + { x: 1605146593977, y: 0, g: 'windows.net' }, + { x: 1605147091074, y: 0, g: 'windows.net' }, + { x: 1605147588171, y: 0, g: 'windows.net' }, + { x: 1605148085268, y: 0, g: 'windows.net' }, + { x: 1605148582365, y: 0, g: 'windows.net' }, + { x: 1605149079462, y: 0, g: 'windows.net' }, + { x: 1605149576559, y: 0, g: 'windows.net' }, + { x: 1605150073656, y: 1, g: 'windows.net' }, + { x: 1605150570753, y: 0, g: 'windows.net' }, + { x: 1605151067850, y: 0, g: 'windows.net' }, + { x: 1605151564947, y: 0, g: 'windows.net' }, + { x: 1605152062044, y: 0, g: 'windows.net' }, + { x: 1605152559141, y: 0, g: 'windows.net' }, + { x: 1605153056238, y: 0, g: 'windows.net' }, + { x: 1605153553335, y: 0, g: 'windows.net' }, + { x: 1605154050432, y: 0, g: 'windows.net' }, + { x: 1605154547529, y: 0, g: 'windows.net' }, + { x: 1605155044626, y: 0, g: 'windows.net' }, + { x: 1605155541723, y: 0, g: 'windows.net' }, + { x: 1605156038820, y: 0, g: 'windows.net' }, + { x: 1605156535917, y: 0, g: 'windows.net' }, + { x: 1605157033014, y: 0, g: 'windows.net' }, + { x: 1605157530111, y: 0, g: 'windows.net' }, + { x: 1605158027208, y: 0, g: 'windows.net' }, + { x: 1605158524305, y: 0, g: 'windows.net' }, + { x: 1605159021402, y: 0, g: 'windows.net' }, + { x: 1605159518499, y: 0, g: 'windows.net' }, ], totalCount: 0, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/__mocks__/index.ts index 3a769127bbe8..6f1e593ca2ed 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/__mocks__/index.ts @@ -18,55 +18,66 @@ export const mockOptions = { ], filterQuery: '{"bool":{"must":[],"filter":[{"match_all":{}}],"should":[],"must_not":[]}}', histogramType: MatrixHistogramType.dns, + isPtrIncluded: false, timerange: { interval: '12h', from: '2020-09-08T15:41:15.528Z', to: '2020-09-09T15:41:15.529Z' }, stackByField: 'dns.question.registered_domain', }; export const expectedDsl = { - index: [ - 'apm-*-transaction*', - 'auditbeat-*', - 'endgame-*', - 'filebeat-*', - 'logs-*', - 'packetbeat-*', - 'winlogbeat-*', - ], allowNoIndices: true, - ignoreUnavailable: true, body: { aggregations: { - NetworkDns: { - date_histogram: { field: '@timestamp', fixed_interval: '2700000ms' }, + dns_count: { cardinality: { field: 'dns.question.registered_domain' } }, + dns_name_query_count: { aggs: { - dns: { - terms: { - field: 'dns.question.registered_domain', - order: { orderAgg: 'desc' }, + bucket_sort: { + bucket_sort: { + from: 0, size: 10, + sort: [{ unique_domains: { order: 'desc' } }, { _key: { order: 'asc' } }], + }, + }, + dns_question_name: { + date_histogram: { + extended_bounds: { max: 1599666075529, min: 1599579675528 }, + field: '@timestamp', + fixed_interval: '2700000ms', + min_doc_count: 0, }, - aggs: { orderAgg: { cardinality: { field: 'dns.question.name' } } }, }, + unique_domains: { cardinality: { field: 'dns.question.name' } }, }, + terms: { field: 'dns.question.registered_domain', size: 1000000 }, }, }, query: { bool: { filter: [ - { bool: { must: [], filter: [{ match_all: {} }], should: [], must_not: [] } }, + { bool: { filter: [{ match_all: {} }], must: [], must_not: [], should: [] } }, { range: { '@timestamp': { + format: 'strict_date_optional_time', gte: '2020-09-08T15:41:15.528Z', lte: '2020-09-09T15:41:15.529Z', - format: 'strict_date_optional_time', }, }, }, ], + must_not: [{ term: { 'dns.question.type': { value: 'PTR' } } }], }, }, - size: 0, - track_total_hits: true, }, + ignoreUnavailable: true, + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + size: 0, + track_total_hits: false, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/helpers.ts index d0fff848b426..9131a9c4be87 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/helpers.ts @@ -17,15 +17,16 @@ export const getDnsParsedData = ( ): MatrixHistogramData[] => { let result: MatrixHistogramData[] = []; data.forEach((bucketData: unknown) => { - const time = get('key', bucketData); + const questionName = get('key', bucketData); const histData = getOr([], keyBucket, bucketData).map( // eslint-disable-next-line @typescript-eslint/naming-convention ({ key, doc_count }: DnsHistogramSubBucket) => ({ - x: time, + x: key, y: doc_count, - g: key, + g: questionName, }) ); + result = [...result, ...histData]; }); return result; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.test.ts index 8afc764d97f8..fcdd13d42c91 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.test.ts @@ -19,8 +19,8 @@ jest.mock('./helpers', () => ({ describe('dnsMatrixHistogramConfig', () => { test('should export dnsMatrixHistogramConfig corrrectly', () => { expect(dnsMatrixHistogramConfig).toEqual({ - aggName: 'aggregations.NetworkDns.buckets', - parseKey: 'dns.buckets', + aggName: 'aggregations.dns_name_query_count.buckets', + parseKey: 'dns_question_name.buckets', buildDsl: buildDnsHistogramQuery, parser: getDnsParsedData, }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.ts index 557e2ebf759e..d592348de7af 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/index.ts @@ -9,7 +9,7 @@ import { getDnsParsedData } from './helpers'; export const dnsMatrixHistogramConfig = { buildDsl: buildDnsHistogramQuery, - aggName: 'aggregations.NetworkDns.buckets', - parseKey: 'dns.buckets', + aggName: 'aggregations.dns_name_query_count.buckets', + parseKey: 'dns_question_name.buckets', parser: getDnsParsedData, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/query.dns_histogram.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/query.dns_histogram.dsl.ts index 08a080865dfc..4374d6b0da89 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/query.dns_histogram.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/matrix_histogram/dns/query.dns_histogram.dsl.ts @@ -4,17 +4,65 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; + +import moment from 'moment'; + +import { Direction, MatrixHistogramRequestOptions } from '../../../../../../common/search_strategy'; import { - createQueryFilterClauses, calculateTimeSeriesInterval, + createQueryFilterClauses, } from '../../../../../utils/build_query'; -import { MatrixHistogramRequestOptions } from '../../../../../../common/search_strategy/security_solution/matrix_histogram'; + +const HUGE_QUERY_SIZE = 1000000; + +const getCountAgg = () => ({ + dns_count: { + cardinality: { + field: 'dns.question.registered_domain', + }, + }, +}); + +const createIncludePTRFilter = (isPtrIncluded: boolean) => + isPtrIncluded + ? {} + : { + must_not: [ + { + term: { + 'dns.question.type': { + value: 'PTR', + }, + }, + }, + ], + }; + +const getHistogramAggregation = ({ from, to }: { from: string; to: string }) => { + const interval = calculateTimeSeriesInterval(from, to); + const histogramTimestampField = '@timestamp'; + + return { + date_histogram: { + field: histogramTimestampField, + fixed_interval: interval, + min_doc_count: 0, + extended_bounds: { + min: moment(from).valueOf(), + max: moment(to).valueOf(), + }, + }, + }; +}; export const buildDnsHistogramQuery = ({ + defaultIndex, + docValueFields, filterQuery, + isPtrIncluded = false, + stackByField = 'dns.question.registered_domain', timerange: { from, to }, - defaultIndex, - stackByField, }: MatrixHistogramRequestOptions) => { const filter = [ ...createQueryFilterClauses(filterQuery), @@ -29,55 +77,48 @@ export const buildDnsHistogramQuery = ({ }, ]; - const getHistogramAggregation = () => { - const interval = calculateTimeSeriesInterval(from, to); - const histogramTimestampField = '@timestamp'; - const dateHistogram = { - date_histogram: { - field: histogramTimestampField, - fixed_interval: interval, - }, - }; - - return { - NetworkDns: { - ...dateHistogram, - aggs: { - dns: { - terms: { - field: stackByField, - order: { - orderAgg: 'desc', + const dslQuery = { + allowNoIndices: true, + index: defaultIndex, + ignoreUnavailable: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggregations: { + ...getCountAgg(), + dns_name_query_count: { + terms: { + field: stackByField, + size: HUGE_QUERY_SIZE, + }, + aggs: { + dns_question_name: getHistogramAggregation({ from, to }), + bucket_sort: { + bucket_sort: { + sort: [ + { unique_domains: { order: Direction.desc } }, + { _key: { order: Direction.asc } }, + ], + from: 0, + size: 10, }, - size: 10, }, - aggs: { - orderAgg: { - cardinality: { - field: 'dns.question.name', - }, + unique_domains: { + cardinality: { + field: 'dns.question.name', }, }, }, }, }, - }; - }; - - const dslQuery = { - index: defaultIndex, - allowNoIndices: true, - ignoreUnavailable: true, - body: { - aggregations: getHistogramAggregation(), query: { bool: { filter, + ...createIncludePTRFilter(isPtrIncluded), }, }, - size: 0, - track_total_hits: true, }, + size: 0, + track_total_hits: false, }; return dslQuery; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts index fbe007622005..0379fa3d32ed 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/__mocks__/index.ts @@ -263,7 +263,101 @@ export const formattedSearchStrategyResponse = { ...mockSearchStrategyResponse, inspect: { dsl: [ - '{\n "allowNoIndices": true,\n "index": [\n "apm-*-transaction*",\n "auditbeat-*",\n "endgame-*",\n "filebeat-*",\n "logs-*",\n "packetbeat-*",\n "winlogbeat-*"\n ],\n "ignoreUnavailable": true,\n "body": {\n "aggs": {\n "source": {\n "filter": {\n "term": {\n "source.ip": "35.196.65.164"\n }\n },\n "aggs": {\n "firstSeen": {\n "min": {\n "field": "@timestamp"\n }\n },\n "lastSeen": {\n "max": {\n "field": "@timestamp"\n }\n },\n "as": {\n "filter": {\n "exists": {\n "field": "source.as"\n }\n },\n "aggs": {\n "results": {\n "top_hits": {\n "size": 1,\n "_source": [\n "source.as"\n ],\n "sort": [\n {\n "@timestamp": "desc"\n }\n ]\n }\n }\n }\n },\n "geo": {\n "filter": {\n "exists": {\n "field": "source.geo"\n }\n },\n "aggs": {\n "results": {\n "top_hits": {\n "size": 1,\n "_source": [\n "source.geo"\n ],\n "sort": [\n {\n "@timestamp": "desc"\n }\n ]\n }\n }\n }\n }\n }\n },\n "destination": {\n "filter": {\n "term": {\n "destination.ip": "35.196.65.164"\n }\n },\n "aggs": {\n "firstSeen": {\n "min": {\n "field": "@timestamp"\n }\n },\n "lastSeen": {\n "max": {\n "field": "@timestamp"\n }\n },\n "as": {\n "filter": {\n "exists": {\n "field": "destination.as"\n }\n },\n "aggs": {\n "results": {\n "top_hits": {\n "size": 1,\n "_source": [\n "destination.as"\n ],\n "sort": [\n {\n "@timestamp": "desc"\n }\n ]\n }\n }\n }\n },\n "geo": {\n "filter": {\n "exists": {\n "field": "destination.geo"\n }\n },\n "aggs": {\n "results": {\n "top_hits": {\n "size": 1,\n "_source": [\n "destination.geo"\n ],\n "sort": [\n {\n "@timestamp": "desc"\n }\n ]\n }\n }\n }\n }\n }\n },\n "host": {\n "filter": {\n "term": {\n "host.ip": "35.196.65.164"\n }\n },\n "aggs": {\n "results": {\n "top_hits": {\n "size": 1,\n "_source": [\n "host"\n ],\n "sort": [\n {\n "@timestamp": "desc"\n }\n ]\n }\n }\n }\n }\n },\n "query": {\n "bool": {\n "should": []\n }\n },\n "size": 0,\n "track_total_hits": false\n }\n}', + JSON.stringify( + { + allowNoIndices: true, + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + ignoreUnavailable: true, + body: { + docvalue_fields: mockOptions.docValueFields, + aggs: { + source: { + filter: { term: { 'source.ip': '35.196.65.164' } }, + aggs: { + firstSeen: { min: { field: '@timestamp' } }, + lastSeen: { max: { field: '@timestamp' } }, + as: { + filter: { exists: { field: 'source.as' } }, + aggs: { + results: { + top_hits: { + size: 1, + _source: ['source.as'], + sort: [{ '@timestamp': 'desc' }], + }, + }, + }, + }, + geo: { + filter: { exists: { field: 'source.geo' } }, + aggs: { + results: { + top_hits: { + size: 1, + _source: ['source.geo'], + sort: [{ '@timestamp': 'desc' }], + }, + }, + }, + }, + }, + }, + destination: { + filter: { term: { 'destination.ip': '35.196.65.164' } }, + aggs: { + firstSeen: { min: { field: '@timestamp' } }, + lastSeen: { max: { field: '@timestamp' } }, + as: { + filter: { exists: { field: 'destination.as' } }, + aggs: { + results: { + top_hits: { + size: 1, + _source: ['destination.as'], + sort: [{ '@timestamp': 'desc' }], + }, + }, + }, + }, + geo: { + filter: { exists: { field: 'destination.geo' } }, + aggs: { + results: { + top_hits: { + size: 1, + _source: ['destination.geo'], + sort: [{ '@timestamp': 'desc' }], + }, + }, + }, + }, + }, + }, + host: { + filter: { term: { 'host.ip': '35.196.65.164' } }, + aggs: { + results: { + top_hits: { size: 1, _source: ['host'], sort: [{ '@timestamp': 'desc' }] }, + }, + }, + }, + }, + query: { bool: { should: [] } }, + size: 0, + track_total_hits: false, + }, + }, + null, + 2 + ), ], }, networkDetails: { @@ -370,6 +464,7 @@ export const expectedDsl = { }, }, }, + docvalue_fields: mockOptions.docValueFields, query: { bool: { should: [] } }, size: 0, track_total_hits: false, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts index 67aeba60c4d2..9661915be6f3 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/details/query.details_network.dsl.ts @@ -106,7 +106,7 @@ export const buildNetworkDetailsQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggs: { ...getAggs('source', ip), ...getAggs('destination', ip), diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/dns/query.dns_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/dns/query.dns_network.dsl.ts index 7043b15ebb4d..1da2e7475453 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/dns/query.dns_network.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/dns/query.dns_network.dsl.ts @@ -91,7 +91,7 @@ export const buildDnsQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { ...getCountAgg(), dns_name_query_count: { @@ -102,7 +102,7 @@ export const buildDnsQuery = ({ aggs: { bucket_sort: { bucket_sort: { - sort: [getQueryOrder(sort), { _key: { order: 'asc' } }], + sort: [getQueryOrder(sort), { _key: { order: Direction.asc } }], from: cursorStart, size: querySize, }, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/__mocks__/index.ts index d105cb621cf4..8a3947924797 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/http/__mocks__/index.ts @@ -9,12 +9,12 @@ import { IEsSearchResponse } from '../../../../../../../../../../src/plugins/dat import { Direction, NetworkDnsFields, - NetworkDnsRequestOptions, + NetworkHttpRequestOptions, NetworkQueries, SortField, } from '../../../../../../../common/search_strategy'; -export const mockOptions: NetworkDnsRequestOptions = { +export const mockOptions: NetworkHttpRequestOptions = { defaultIndex: [ 'apm-*-transaction*', 'auditbeat-*', @@ -29,7 +29,7 @@ export const mockOptions: NetworkDnsRequestOptions = { pagination: { activePage: 0, cursorStart: 0, fakePossibleCount: 50, querySize: 10 }, sort: { direction: Direction.desc } as SortField, timerange: { interval: '12h', from: '2020-09-13T09:00:43.249Z', to: '2020-09-14T09:00:43.249Z' }, -} as NetworkDnsRequestOptions; +} as NetworkHttpRequestOptions; export const mockSearchStrategyResponse: IEsSearchResponse = { isPartial: false, diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts index 09551dd06930..a5a0c877ecdd 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts @@ -58,7 +58,7 @@ export const buildTimelineEventsAllQuery = ({ index: defaultIndex, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), query: { bool: { filter, diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts index 0f4eabf69291..17563bfdbe24 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts @@ -40,7 +40,7 @@ export const buildLastEventTimeQuery = ({ index: indicesToQuery.network, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { last_seen_event: { max: { field: '@timestamp' } }, }, @@ -58,7 +58,7 @@ export const buildLastEventTimeQuery = ({ index: indicesToQuery.hosts, ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { last_seen_event: { max: { field: '@timestamp' } }, }, @@ -76,7 +76,7 @@ export const buildLastEventTimeQuery = ({ index: indicesToQuery[indexKey], ignoreUnavailable: true, body: { - ...(isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), aggregations: { last_seen_event: { max: { field: '@timestamp' } }, }, From b312c910105f0be3f1c7488b211b60fca310ca48 Mon Sep 17 00:00:00 2001 From: Daniil Date: Tue, 24 Nov 2020 18:16:06 +0300 Subject: [PATCH 25/89] Fix timelion vis escapes single quotes (#84196) * Remove string escaping * Add unit test --- .../public/__snapshots__/to_ast.test.ts.snap | 20 +++++++++++++++++++ .../vis_type_timelion/public/to_ast.test.ts | 6 ++++++ .../vis_type_timelion/public/to_ast.ts | 10 ++++------ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/plugins/vis_type_timelion/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_timelion/public/__snapshots__/to_ast.test.ts.snap index 9e32a6c4ae17..7635e5214795 100644 --- a/src/plugins/vis_type_timelion/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_type_timelion/public/__snapshots__/to_ast.test.ts.snap @@ -19,3 +19,23 @@ Object { "type": "expression", } `; + +exports[`timelion vis toExpressionAst function should not escape single quotes 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "expression": Array [ + ".es(index=my*,timefield=\\"date\\",split='test field:3',metric='avg:value')", + ], + "interval": Array [ + "auto", + ], + }, + "function": "timelion_vis", + "type": "function", + }, + ], + "type": "expression", +} +`; diff --git a/src/plugins/vis_type_timelion/public/to_ast.test.ts b/src/plugins/vis_type_timelion/public/to_ast.test.ts index 8a9d4b83f94d..f2030e4b83c1 100644 --- a/src/plugins/vis_type_timelion/public/to_ast.test.ts +++ b/src/plugins/vis_type_timelion/public/to_ast.test.ts @@ -37,4 +37,10 @@ describe('timelion vis toExpressionAst function', () => { const actual = toExpressionAst(vis); expect(actual).toMatchSnapshot(); }); + + it('should not escape single quotes', () => { + vis.params.expression = `.es(index=my*,timefield="date",split='test field:3',metric='avg:value')`; + const actual = toExpressionAst(vis); + expect(actual).toMatchSnapshot(); + }); }); diff --git a/src/plugins/vis_type_timelion/public/to_ast.ts b/src/plugins/vis_type_timelion/public/to_ast.ts index 7044bbf4e583..535e8e8fe0f7 100644 --- a/src/plugins/vis_type_timelion/public/to_ast.ts +++ b/src/plugins/vis_type_timelion/public/to_ast.ts @@ -21,14 +21,12 @@ import { buildExpression, buildExpressionFunction } from '../../expressions/publ import { Vis } from '../../visualizations/public'; import { TimelionExpressionFunctionDefinition, TimelionVisParams } from './timelion_vis_fn'; -const escapeString = (data: string): string => { - return data.replace(/\\/g, `\\\\`).replace(/'/g, `\\'`); -}; - export const toExpressionAst = (vis: Vis) => { + const { expression, interval } = vis.params; + const timelion = buildExpressionFunction('timelion_vis', { - expression: escapeString(vis.params.expression), - interval: escapeString(vis.params.interval), + expression, + interval, }); const ast = buildExpression([timelion]); From 5f844bfb6a39e17010d8a331ea5b0338693b5f36 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Tue, 24 Nov 2020 16:59:18 +0100 Subject: [PATCH 26/89] update geckodriver to 0.28 (#84085) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 39149c801da4..af80102641db 100644 --- a/package.json +++ b/package.json @@ -654,7 +654,7 @@ "file-loader": "^4.2.0", "file-saver": "^1.3.8", "formsy-react": "^1.1.5", - "geckodriver": "^1.20.0", + "geckodriver": "^1.21.0", "glob-watcher": "5.0.3", "graphql-code-generator": "^0.18.2", "graphql-codegen-add": "^0.18.2", diff --git a/yarn.lock b/yarn.lock index d20c7f78c979..8d47d3e84378 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14255,10 +14255,10 @@ gaze@^1.0.0, gaze@^1.1.0: dependencies: globule "^1.0.0" -geckodriver@^1.20.0: - version "1.20.0" - resolved "https://registry.yarnpkg.com/geckodriver/-/geckodriver-1.20.0.tgz#cd16edb177b88e31affcb54b18a238cae88950a7" - integrity sha512-5nVF4ixR+ZGhVsc4udnVihA9RmSlO6guPV1d2HqxYsgAOUNh0HfzxbzG7E49w4ilXq/CSu87x9yWvrsOstrADQ== +geckodriver@^1.21.0: + version "1.21.0" + resolved "https://registry.yarnpkg.com/geckodriver/-/geckodriver-1.21.0.tgz#1f04780ebfb451ffd08fa8fddc25cc26e37ac4a2" + integrity sha512-NamdJwGIWpPiafKQIvGman95BBi/SBqHddRXAnIEpFNFCFToTW0sEA0nUckMKCBNn1DVIcLfULfyFq/sTn9bkA== dependencies: adm-zip "0.4.16" bluebird "3.7.2" From a12bb044382ada8f557590851943a1568f24c58f Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Tue, 24 Nov 2020 10:16:33 -0600 Subject: [PATCH 27/89] [Workplace Search] Initial rendering of Org Sources (#84164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix broken routes Didn’t have a way to test these when created * Get context from global state No need to do this in 2 places now. There was a race condition where the default logic value for `isOrganization` was set to `false` We don’t need a useEffect call here because the value is synchronous and has no side effects. Calling the method directly fixes the race condition. * Add the ‘path’ to the logic files for easier debugging * Add SourceSubNav component * Flip routes to match new convention It was decided by Product that instead of keying off of `/org` to determine context, that we would now flip it where we key of provate with `/p`. This means that /sources is now organization where before it was personal * Convert routers to use children instead of props This aligns with App Search and allows for easier telemtry and breadcrumbs * Add breadcrumbs and basic telemetry * Add in and refactor subnavigation As a part of this commit, the approach for rendering subnavs was refactored to align with App Search. There was a bug where some components weren’t rendering properly because the SourceLogic and GroupsLogic files were never unmounting. The reason for this is the subnav components use their respective logic files to get the IDs needed for rendering the subnav links. That is, SourceSubNav would call SourceLogic to get the ID to render the links and would stay rendered for the duration of the user’s time in the app. The result is that users would leave the source details page and navigate to add a new source and the logic file would never reset to a loading state and the UI would break. The fix was to borrow from the pattern App Search uses and pass the subnavs as props. Because App Search only uses a single engines subnav, they only needed one prop. We use multiple props for each subnav. Also, the subnav should not be rendered on the root routes (/sources, /p/sources, and /groups) so conditionals were added to only render the subnavs when not on those root routes. * Add FlashMessages * Fix some failed tests Missed this in first commit * Update SourceIcon to use EuiIcon Before this change, the legacy styles were not ported over. This gives a uniform size for both wrapped and unwrapped icons. The icons are a bit smaller on the add source page but Eui has lowered it’s largest size ‘xxl’ and we would need to write manual overrides. IMO the change is not significant enough to override. * Fix broken icons * Replace legacy div with component The eui.css file in ent-search is no longer up to date with current EUI and this was broken. The best fix was to use the component that renders as expected * Add base styles for Sources More in a future PR but this makes the majority of things look correct. * Cleanup Fix some type errors and rename constants * Couple more failing tests We have multiple `Layouts` now with the new subnavs * Fix prepare routes Like the first commit, missed these when porting over routes with no UI. * Clean up the desgin of the source connect screen The columns were way off in Kibana * Remove ORG_PATH const No longer needed since ‘/org’ is gone --- .../components/layout/nav.tsx | 13 +- .../shared/assets/source_icons/index.ts | 5 + .../shared/source_icon/source_icon.scss | 21 +++ .../shared/source_icon/source_icon.test.tsx | 6 +- .../shared/source_icon/source_icon.tsx | 13 +- .../workplace_search/constants.ts | 12 ++ .../workplace_search/index.test.tsx | 4 +- .../applications/workplace_search/index.tsx | 44 ++++-- .../workplace_search/routes.test.tsx | 6 +- .../applications/workplace_search/routes.ts | 24 ++-- .../add_source/configured_sources_list.tsx | 5 +- .../add_source/connect_instance.tsx | 4 +- .../components/source_sub_nav.tsx | 59 ++++++++ .../views/content_sources/index.ts | 1 + .../views/content_sources/source_logic.ts | 3 +- .../views/content_sources/source_router.tsx | 62 ++++---- .../views/content_sources/sources.scss | 23 +++ .../views/content_sources/sources_logic.ts | 1 + .../views/content_sources/sources_router.tsx | 133 ++++++++++-------- .../groups/components/group_manager_modal.tsx | 4 +- .../views/overview/onboarding_steps.test.tsx | 4 +- .../views/overview/onboarding_steps.tsx | 4 +- .../views/overview/organization_stats.tsx | 4 +- .../routes/workplace_search/sources.test.ts | 16 +-- .../server/routes/workplace_search/sources.ts | 18 +-- 25 files changed, 332 insertions(+), 157 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 6fa6698e6b6b..de6c75d60189 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -11,11 +11,9 @@ import { WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; import { SideNav, SideNavLink } from '../../../shared/layout'; -import { GroupSubNav } from '../../views/groups/components/group_sub_nav'; import { NAV } from '../../constants'; import { - ORG_SOURCES_PATH, SOURCES_PATH, SECURITY_PATH, ROLE_MAPPINGS_PATH, @@ -23,17 +21,22 @@ import { ORG_SETTINGS_PATH, } from '../../routes'; -export const WorkplaceSearchNav: React.FC = () => { +interface Props { + sourcesSubNav?: React.ReactNode; + groupsSubNav?: React.ReactNode; +} + +export const WorkplaceSearchNav: React.FC = ({ sourcesSubNav, groupsSubNav }) => { // TODO: icons return ( {NAV.OVERVIEW} - + {NAV.SOURCES} - }> + {NAV.GROUPS} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts index 5f93694da09b..2ac3f518e4e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts @@ -30,22 +30,27 @@ import zendesk from './zendesk.svg'; export const images = { box, confluence, + confluenceCloud: confluence, + confluenceServer: confluence, crawler, custom, drive, dropbox, github, + githubEnterpriseServer: github, gmail, googleDrive, google, jira, jiraServer, + jiraCloud: jira, loadingSmall, office365, oneDrive, outlook, people, salesforce, + salesforceSandbox: salesforce, serviceNow, sharePoint, slack, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.scss new file mode 100644 index 000000000000..b04d5b8bc218 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.scss @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +.wrapped-icon { + width: 30px; + height: 30px; + overflow: hidden; + margin-right: 4px; + position: relative; + display: flex; + justify-content: center; + align-items: center; + + img { + max-width: 100%; + max-height: 100%; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx index c17b89c93a28..4007f7a69f77 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.test.tsx @@ -7,19 +7,21 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiIcon } from '@elastic/eui'; + import { SourceIcon } from './'; describe('SourceIcon', () => { it('renders unwrapped icon', () => { const wrapper = shallow(); - expect(wrapper.find('img')).toHaveLength(1); + expect(wrapper.find(EuiIcon)).toHaveLength(1); expect(wrapper.find('.user-group-source')).toHaveLength(0); }); it('renders wrapped icon', () => { const wrapper = shallow(); - expect(wrapper.find('.user-group-source')).toHaveLength(1); + expect(wrapper.find('.wrapped-icon')).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx index dec9e25fe244..1af5420a164b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_icon/source_icon.tsx @@ -8,6 +8,10 @@ import React from 'react'; import { camelCase } from 'lodash'; +import { EuiIcon } from '@elastic/eui'; + +import './source_icon.scss'; + import { images } from '../assets/source_icons'; import { imagesFull } from '../assets/sources_full_bleed'; @@ -27,14 +31,15 @@ export const SourceIcon: React.FC = ({ fullBleed = false, }) => { const icon = ( - {name} ); return wrapped ? ( -

    +
    {icon}
    ) : ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 1846115d7390..327ee7b30582 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -25,15 +25,27 @@ export const NAV = { 'xpack.enterpriseSearch.workplaceSearch.nav.groups.sourcePrioritization', { defaultMessage: 'Source Prioritization' } ), + CONTENT: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.content', { + defaultMessage: 'Content', + }), ROLE_MAPPINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.roleMappings', { defaultMessage: 'Role Mappings', }), SECURITY: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.security', { defaultMessage: 'Security', }), + SCHEMA: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.schema', { + defaultMessage: 'Schema', + }), + DISPLAY_SETTINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.displaySettings', { + defaultMessage: 'Display Settings', + }), SETTINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.settings', { defaultMessage: 'Settings', }), + ADD_SOURCE: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.addSource', { + defaultMessage: 'Add Source', + }), PERSONAL_DASHBOARD: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.nav.personalDashboard', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 5f1e2dd18d3b..20b15bcfc45c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -57,7 +57,7 @@ describe('WorkplaceSearchConfigured', () => { it('renders layout and header actions', () => { const wrapper = shallow(); - expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy(); + expect(wrapper.find(Layout).first().prop('readOnlyMode')).toBeFalsy(); expect(wrapper.find(Overview)).toHaveLength(1); expect(mockKibanaValues.renderHeaderActions).toHaveBeenCalledWith(WorkplaceSearchHeaderActions); }); @@ -90,6 +90,6 @@ describe('WorkplaceSearchConfigured', () => { const wrapper = shallow(); - expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true); + expect(wrapper.find(Layout).first().prop('readOnlyMode')).toEqual(true); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 776cae24dfdf..562a2ffb3288 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -16,13 +16,17 @@ import { AppLogic } from './app_logic'; import { Layout } from '../shared/layout'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; -import { GROUPS_PATH, SETUP_GUIDE_PATH } from './routes'; +import { GROUPS_PATH, SETUP_GUIDE_PATH, SOURCES_PATH, PERSONAL_SOURCES_PATH } from './routes'; import { SetupGuide } from './views/setup_guide'; import { ErrorState } from './views/error_state'; import { NotFound } from '../shared/not_found'; import { Overview } from './views/overview'; import { GroupsRouter } from './views/groups'; +import { SourcesRouter } from './views/content_sources'; + +import { GroupSubNav } from './views/groups/components/group_sub_nav'; +import { SourceSubNav } from './views/content_sources/components/source_sub_nav'; export const WorkplaceSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); @@ -37,6 +41,10 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { const { pathname } = useLocation(); + // We don't want so show the subnavs on the container root pages. + const showSourcesSubnav = pathname !== SOURCES_PATH && pathname !== PERSONAL_SOURCES_PATH; + const showGroupsSubnav = pathname !== GROUPS_PATH; + /** * Personal dashboard urls begin with /p/ * EX: http://localhost:5601/app/enterprise_search/workplace_search/p/sources @@ -45,6 +53,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { // TODO: Once auth is figured out, we need to have a check for the equivilent of `isAdmin`. const isOrganization = !pathname.match(personalSourceUrlRegex); + setContext(isOrganization); useEffect(() => { if (!hasInitialized) { @@ -53,10 +62,6 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { } }, [hasInitialized]); - useEffect(() => { - setContext(isOrganization); - }, [isOrganization]); - return ( @@ -65,19 +70,32 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { {errorConnecting ? : } + + } />} + restrictWidth + readOnlyMode={readOnlyMode} + > + + + + + } />} + restrictWidth + readOnlyMode={readOnlyMode} + > + + + } restrictWidth readOnlyMode={readOnlyMode}> {errorConnecting ? ( ) : ( - - - - - - - - + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx index d03c0abb441b..3fddcf3b77fe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx @@ -12,7 +12,7 @@ import { EuiLink } from '@elastic/eui'; import { getContentSourcePath, SOURCES_PATH, - ORG_SOURCES_PATH, + PERSONAL_SOURCES_PATH, SOURCE_DETAILS_PATH, } from './routes'; @@ -26,13 +26,13 @@ describe('getContentSourcePath', () => { const wrapper = shallow(); const path = wrapper.find(EuiLink).prop('href'); - expect(path).toEqual(`${ORG_SOURCES_PATH}/123`); + expect(path).toEqual(`${SOURCES_PATH}/123`); }); it('should format user route', () => { const wrapper = shallow(); const path = wrapper.find(EuiLink).prop('href'); - expect(path).toEqual(`${SOURCES_PATH}/123`); + expect(path).toEqual(`${PERSONAL_SOURCES_PATH}/123`); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index e41a043911dc..3ec22ede888a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -44,21 +44,21 @@ export const CUSTOM_API_DOCS_URL = `${DOCS_PREFIX}/workplace-search-custom-sourc export const CUSTOM_API_DOCUMENT_PERMISSIONS_DOCS_URL = `${CUSTOM_SOURCE_DOCS_URL}#custom-api-source-document-level-access-control`; export const ENT_SEARCH_LICENSE_MANAGEMENT = `${ENT_SEARCH_DOCS_PREFIX}/license-management.html`; -export const ORG_PATH = '/org'; +export const PERSONAL_PATH = '/p'; -export const ROLE_MAPPINGS_PATH = `${ORG_PATH}/role-mappings`; +export const ROLE_MAPPINGS_PATH = '/role-mappings'; export const ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/:roleId`; export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; -export const USERS_PATH = `${ORG_PATH}/users`; -export const SECURITY_PATH = `${ORG_PATH}/security`; +export const USERS_PATH = '/users'; +export const SECURITY_PATH = '/security'; export const GROUPS_PATH = '/groups'; export const GROUP_PATH = `${GROUPS_PATH}/:groupId`; export const GROUP_SOURCE_PRIORITIZATION_PATH = `${GROUPS_PATH}/:groupId/source_prioritization`; export const SOURCES_PATH = '/sources'; -export const ORG_SOURCES_PATH = `${ORG_PATH}${SOURCES_PATH}`; +export const PERSONAL_SOURCES_PATH = `${PERSONAL_PATH}${SOURCES_PATH}`; export const SOURCE_ADDED_PATH = `${SOURCES_PATH}/added`; export const ADD_SOURCE_PATH = `${SOURCES_PATH}/add`; @@ -81,7 +81,7 @@ export const ADD_SLACK_PATH = `${SOURCES_PATH}/add/slack`; export const ADD_ZENDESK_PATH = `${SOURCES_PATH}/add/zendesk`; export const ADD_CUSTOM_PATH = `${SOURCES_PATH}/add/custom`; -export const PERSONAL_SETTINGS_PATH = '/settings'; +export const PERSONAL_SETTINGS_PATH = `${PERSONAL_PATH}/settings`; export const SOURCE_DETAILS_PATH = `${SOURCES_PATH}/:sourceId`; export const SOURCE_CONTENT_PATH = `${SOURCES_PATH}/:sourceId/content`; @@ -93,7 +93,7 @@ export const REINDEX_JOB_PATH = `${SOURCES_PATH}/:sourceId/schema-errors/:active export const DISPLAY_SETTINGS_SEARCH_RESULT_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/`; export const DISPLAY_SETTINGS_RESULT_DETAIL_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/result-detail`; -export const ORG_SETTINGS_PATH = `${ORG_PATH}/settings`; +export const ORG_SETTINGS_PATH = '/settings'; export const ORG_SETTINGS_CUSTOMIZE_PATH = `${ORG_SETTINGS_PATH}/customize`; export const ORG_SETTINGS_CONNECTORS_PATH = `${ORG_SETTINGS_PATH}/connectors`; export const ORG_SETTINGS_OAUTH_APPLICATION_PATH = `${ORG_SETTINGS_PATH}/oauth`; @@ -120,9 +120,9 @@ export const getContentSourcePath = ( path: string, sourceId: string, isOrganization: boolean -): string => generatePath(isOrganization ? ORG_PATH + path : path, { sourceId }); -export const getGroupPath = (groupId: string) => generatePath(GROUP_PATH, { groupId }); -export const getGroupSourcePrioritizationPath = (groupId: string) => +): string => generatePath(isOrganization ? path : `${PERSONAL_PATH}${path}`, { sourceId }); +export const getGroupPath = (groupId: string): string => generatePath(GROUP_PATH, { groupId }); +export const getGroupSourcePrioritizationPath = (groupId: string): string => `${GROUPS_PATH}/${groupId}/source_prioritization`; -export const getSourcesPath = (path: string, isOrganization: boolean) => - isOrganization ? `${ORG_PATH}${path}` : path; +export const getSourcesPath = (path: string, isOrganization: boolean): string => + isOrganization ? path : `${PERSONAL_PATH}${path}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx index a95d5ca75b0b..fbd053f9b837 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx @@ -13,6 +13,7 @@ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, + EuiPanel, EuiSpacer, EuiText, EuiTitle, @@ -57,7 +58,7 @@ export const ConfiguredSourcesList: React.FC = ({ {sources.map(({ name, serviceType, addPath, connected, accountContextOnly }, i) => ( -
    + = ({ )} -
    +
    ))} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx index ad183181b4ec..f9123ab4e1cc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx @@ -240,13 +240,13 @@ export const ConnectInstance: React.FC = ({ gutterSize="xl" responsive={false} > - + {header} {featureBadgeGroup()} {descriptionBlock} {formFields} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx new file mode 100644 index 000000000000..cc68a62b9555 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_sub_nav.tsx @@ -0,0 +1,59 @@ +/* + * 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 { useValues } from 'kea'; + +import { AppLogic } from '../../../app_logic'; +import { NAV, CUSTOM_SERVICE_TYPE } from '../../../constants'; + +import { SourceLogic } from '../source_logic'; + +import { SideNavLink } from '../../../../shared/layout'; + +import { + getContentSourcePath, + SOURCE_DETAILS_PATH, + SOURCE_CONTENT_PATH, + SOURCE_SCHEMAS_PATH, + SOURCE_DISPLAY_SETTINGS_PATH, + SOURCE_SETTINGS_PATH, +} from '../../../routes'; + +export const SourceSubNav: React.FC = () => { + const { isOrganization } = useValues(AppLogic); + const { + contentSource: { id, serviceType }, + } = useValues(SourceLogic); + + if (!id) return null; + + const isCustom = serviceType === CUSTOM_SERVICE_TYPE; + + return ( + <> + + {NAV.OVERVIEW} + + + {NAV.CONTENT} + + {isCustom && ( + <> + + {NAV.SCHEMA} + + + {NAV.DISPLAY_SETTINGS} + + + )} + + {NAV.SETTINGS} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/index.ts index 0ef2099968f1..f447751e9659 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/index.ts @@ -5,3 +5,4 @@ */ export { Overview } from './components/overview'; +export { SourcesRouter } from './sources_router'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts index 0a11da02dc78..51b5735f0104 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_logic.ts @@ -146,6 +146,7 @@ interface PreContentSourceResponse { } export const SourceLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'source_logic'], actions: { onInitializeSource: (contentSource: ContentSourceFullData) => contentSource, onUpdateSourceName: (name: string) => name, @@ -601,7 +602,7 @@ export const SourceLogic = kea>({ try { const response = await HttpLogic.values.http.post(route, { - body: JSON.stringify({ params }), + body: JSON.stringify({ ...params }), }); actions.setCustomSourceData(response); successCallback(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx index b8b8e6e1040a..7161e613247c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_router.tsx @@ -13,6 +13,11 @@ import { Route, Switch, useHistory, useParams } from 'react-router-dom'; import { EuiButton, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; + +import { NAV } from '../../constants'; + import { ENT_SEARCH_LICENSE_MANAGEMENT, REINDEX_JOB_PATH, @@ -99,39 +104,42 @@ export const SourceRouter: React.FC = () => { {/* TODO: Figure out with design how to make this look better */} {pageHeader} - - + + + + + + + + + + {isCustomSource && ( - + + + + + )} {isCustomSource && ( - + + + + + )} {isCustomSource && ( - + + + + + )} - + + + + + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss new file mode 100644 index 000000000000..fb0cecc18148 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources.scss @@ -0,0 +1,23 @@ +/* + * 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. + */ + +.source-grid-configured { + + .source-card-configured { + padding: 8px; + + &__icon { + width: 2em; + height: 2em; + } + + &__not-connected-tooltip { + position: relative; + top: 3px; + left: 4px; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index 600b5871fc49..1757f2a6414f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -78,6 +78,7 @@ interface ISourcesServerResponse { } export const SourcesLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'sources_logic'], actions: { setServerSourceStatuses: (statuses: ContentSourceStatus[]) => statuses, onInitializeSources: (serverResponse: ISourcesServerResponse) => serverResponse, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index e4f15286145f..9f96a13e272d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -10,18 +10,23 @@ import { Location } from 'history'; import { useActions, useValues } from 'kea'; import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; +import { SetWorkplaceSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; + import { LicensingLogic } from '../../../../applications/shared/licensing'; +import { NAV } from '../../constants'; import { ADD_SOURCE_PATH, SOURCE_ADDED_PATH, SOURCE_DETAILS_PATH, - ORG_PATH, - ORG_SOURCES_PATH, + PERSONAL_SOURCES_PATH, SOURCES_PATH, getSourcesPath, } from '../../routes'; +import { FlashMessages } from '../../../shared/flash_messages'; + import { AppLogic } from '../../app_logic'; import { staticSourceData } from './source_data'; import { SourcesLogic } from './sources_logic'; @@ -32,12 +37,15 @@ import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; import { SourceRouter } from './source_router'; +import './sources.scss'; + export const SourcesRouter: React.FC = () => { const { pathname } = useLocation() as Location; const { hasPlatinumLicense } = useValues(LicensingLogic); const { resetSourcesState } = useActions(SourcesLogic); const { account: { canCreatePersonalSources }, + isOrganization, } = useValues(AppLogic); /** @@ -48,61 +56,76 @@ export const SourcesRouter: React.FC = () => { resetSourcesState(); }, [pathname]); - const isOrgRoute = pathname.includes(ORG_PATH); - return ( - - - - {staticSourceData.map(({ addPath, accountContextOnly }, i) => ( - - !hasPlatinumLicense && accountContextOnly ? ( - + <> + + + + + + + + + + + + + {staticSourceData.map(({ addPath, accountContextOnly, name }, i) => ( + + + {!hasPlatinumLicense && accountContextOnly ? ( + ) : ( - ) - } - /> - ))} - {staticSourceData.map(({ addPath }, i) => ( - } - /> - ))} - {staticSourceData.map(({ addPath }, i) => ( - } - /> - ))} - {staticSourceData.map(({ addPath, configuration: { needsConfiguration } }, i) => { - if (needsConfiguration) - return ( - } - /> - ); - })} - {canCreatePersonalSources ? ( - - ) : ( - - )} - : - - - + )} + + ))} + {staticSourceData.map(({ addPath, name }, i) => ( + + + + + ))} + {staticSourceData.map(({ addPath, name }, i) => ( + + + + + ))} + {staticSourceData.map(({ addPath, name, configuration: { needsConfiguration } }, i) => { + if (needsConfiguration) + return ( + + + + + ); + })} + {canCreatePersonalSources ? ( + + + + + + ) : ( + + )} + + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx index c0f8bf57989c..cbfb22915c4e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx @@ -29,7 +29,7 @@ import { import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { Group } from '../../../types'; -import { ORG_SOURCES_PATH } from '../../../routes'; +import { SOURCES_PATH } from '../../../routes'; import noSharedSourcesIcon from '../../../assets/share_circle.svg'; @@ -96,7 +96,7 @@ export const GroupManagerModal: React.FC = ({ const handleSelectAll = () => selectAll(allSelected ? [] : allItems); const sourcesButton = ( - + {ADD_SOURCE_BUTTON_TEXT} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx index 268e4f8da445..64dc5149decd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.test.tsx @@ -11,7 +11,7 @@ import { setMockValues } from './__mocks__'; import React from 'react'; import { shallow } from 'enzyme'; -import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; +import { SOURCES_PATH, USERS_PATH } from '../../routes'; import { OnboardingSteps, OrgNameOnboarding } from './onboarding_steps'; import { OnboardingCard } from './onboarding_card'; @@ -32,7 +32,7 @@ describe('OnboardingSteps', () => { const wrapper = shallow(); expect(wrapper.find(OnboardingCard)).toHaveLength(1); - expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(ORG_SOURCES_PATH); + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(SOURCES_PATH); expect(wrapper.find(OnboardingCard).prop('description')).toBe( 'Add shared sources for your organization to start searching.' ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx index ed5136a6f7a4..4957324aa6bd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx @@ -24,7 +24,7 @@ import { import sharedSourcesIcon from '../../components/shared/assets/source_icons/share_circle.svg'; import { TelemetryLogic } from '../../../shared/telemetry'; import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; -import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; +import { SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; import { ContentSection } from '../../components/shared/content_section'; @@ -75,7 +75,7 @@ export const OnboardingSteps: React.FC = () => { const accountsPath = !isFederatedAuth && (canCreateInvitations || isCurated) ? USERS_PATH : undefined; - const sourcesPath = canCreateContentSources || isCurated ? ORG_SOURCES_PATH : undefined; + const sourcesPath = canCreateContentSources || isCurated ? SOURCES_PATH : undefined; const SOURCES_CARD_DESCRIPTION = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.description', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx index 6614ac58b074..06c620ad384e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx @@ -12,7 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { ContentSection } from '../../components/shared/content_section'; -import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; +import { SOURCES_PATH, USERS_PATH } from '../../routes'; import { AppLogic } from '../../app_logic'; import { OverviewLogic } from './overview_logic'; @@ -43,7 +43,7 @@ export const OrganizationStats: React.FC = () => { { defaultMessage: 'Shared sources' } )} count={sourcesCount} - actionPath={ORG_SOURCES_PATH} + actionPath={SOURCES_PATH} /> {!isFederatedAuth && ( <> diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts index 9cf491b79fd2..22e2deaace1d 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts @@ -328,10 +328,8 @@ describe('sources routes', () => { const mockRequest = { params: { id: '123' }, body: { - query: { - content_source: { - name: 'foo', - }, + content_source: { + name: 'foo', }, }, }; @@ -406,7 +404,7 @@ describe('sources routes', () => { mockRouter.callRoute(mockRequest); expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/ws/pre_content_sources/zendesk', + path: '/ws/sources/zendesk/prepare', }); }); }); @@ -732,10 +730,8 @@ describe('sources routes', () => { const mockRequest = { params: { id: '123' }, body: { - query: { - content_source: { - name: 'foo', - }, + content_source: { + name: 'foo', }, }, }; @@ -810,7 +806,7 @@ describe('sources routes', () => { mockRouter.callRoute(mockRequest); expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/ws/org/pre_content_sources/zendesk', + path: '/ws/org/sources/zendesk/prepare', }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index bdd048438dae..24473388c03b 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -200,10 +200,8 @@ export function registerAccountSourceSettingsRoute({ path: '/api/workplace_search/account/sources/{id}/settings', validate: { body: schema.object({ - query: schema.object({ - content_source: schema.object({ - name: schema.string(), - }), + content_source: schema.object({ + name: schema.string(), }), }), params: schema.object({ @@ -256,7 +254,7 @@ export function registerAccountPrepareSourcesRoute({ }, async (context, request, response) => { return enterpriseSearchRequestHandler.createRequest({ - path: `/ws/pre_content_sources/${request.params.service_type}`, + path: `/ws/sources/${request.params.service_type}/prepare`, })(context, request, response); } ); @@ -372,7 +370,7 @@ export function registerOrgCreateSourceRoute({ login: schema.maybe(schema.string()), password: schema.maybe(schema.string()), organizations: schema.maybe(schema.arrayOf(schema.string())), - indexPermissions: schema.boolean(), + indexPermissions: schema.maybe(schema.boolean()), }), }, }, @@ -462,10 +460,8 @@ export function registerOrgSourceSettingsRoute({ path: '/api/workplace_search/org/sources/{id}/settings', validate: { body: schema.object({ - query: schema.object({ - content_source: schema.object({ - name: schema.string(), - }), + content_source: schema.object({ + name: schema.string(), }), }), params: schema.object({ @@ -518,7 +514,7 @@ export function registerOrgPrepareSourcesRoute({ }, async (context, request, response) => { return enterpriseSearchRequestHandler.createRequest({ - path: `/ws/org/pre_content_sources/${request.params.service_type}`, + path: `/ws/org/sources/${request.params.service_type}/prepare`, })(context, request, response); } ); From 6ef6c0fa4deeace16f47aaf08195b2c10278150b Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Tue, 24 Nov 2020 19:19:06 +0300 Subject: [PATCH 28/89] TSVB should use "histogram:maxBars" and "histogram:barTarget" settings for auto instead of a default 100 buckets (#83628) * TSVB needs a "tsvb:max_buckets" target setting for auto instead of a default 120 buckets Closes: #54012 * remove calculate_auto * max bars -> Level of detail * rename allowLevelofDetail * fix PR comment * Update constants.ts * Update src/plugins/vis_type_timeseries/public/application/components/index_pattern.js Co-authored-by: Wylie Conlon * create LEVEL_OF_DETAIL_MIN_BUCKETS constant * calcAutoIntervalLessThan -> search.aggs.calcAutoIntervalLessThan Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Wylie Conlon --- ...ibana-plugin-plugins-data-server.search.md | 1 + .../data/common/search/aggs/buckets/index.ts | 1 + src/plugins/data/server/index.ts | 2 + src/plugins/data/server/server.api.md | 32 ++--- .../vis_type_timeseries/common/constants.ts | 2 +- .../vis_type_timeseries/common/vis_schema.ts | 2 + .../application/components/index_pattern.js | 115 ++++++++++++++++-- .../components/lib/get_interval.js | 3 +- .../components/panel_config/timeseries.js | 1 + .../components/vis_editor_visualization.js | 2 +- .../components/vis_types/timeseries/config.js | 2 +- .../annotations/get_request_params.js | 8 +- .../vis_data/get_interval_and_timefield.js | 14 ++- .../server/lib/vis_data/get_table_data.js | 8 +- .../lib/vis_data/helpers/calculate_auto.js | 90 -------------- .../lib/vis_data/helpers/get_bucket_size.js | 15 +-- .../vis_data/helpers/get_bucket_size.test.js | 18 ++- ...imerange.test.js => get_timerange.test.ts} | 8 +- .../{get_timerange.js => get_timerange.ts} | 15 +-- .../annotations/date_histogram.js | 10 +- .../request_processors/annotations/query.js | 12 +- .../series/date_histogram.js | 23 +++- .../series/date_histogram.test.js | 70 ++++++++--- .../series/metric_buckets.js | 13 +- .../series/metric_buckets.test.js | 84 +++++++------ .../series/positive_rate.js | 13 +- .../series/positive_rate.test.js | 9 +- .../series/sibling_buckets.js | 6 +- .../series/sibling_buckets.test.js | 10 +- .../table/date_histogram.js | 16 ++- .../table/metric_buckets.js | 12 +- .../request_processors/table/positive_rate.js | 12 +- .../table/sibling_buckets.js | 12 +- .../series/build_request_body.test.ts | 3 +- .../lib/vis_data/series/get_request_params.js | 9 +- 35 files changed, 424 insertions(+), 229 deletions(-) delete mode 100644 src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/calculate_auto.js rename src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/{get_timerange.test.js => get_timerange.test.ts} (92%) rename src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/{get_timerange.js => get_timerange.ts} (75%) diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md index e2a71a7badd4..77abcacd7704 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md @@ -52,6 +52,7 @@ search: { siblingPipelineType: string; termsAggFilter: string[]; toAbsoluteDates: typeof toAbsoluteDates; + calcAutoIntervalLessThan: typeof calcAutoIntervalLessThan; }; getRequestInspectorStats: typeof getRequestInspectorStats; getResponseInspectorStats: typeof getResponseInspectorStats; diff --git a/src/plugins/data/common/search/aggs/buckets/index.ts b/src/plugins/data/common/search/aggs/buckets/index.ts index b16242e51987..04a748bfb196 100644 --- a/src/plugins/data/common/search/aggs/buckets/index.ts +++ b/src/plugins/data/common/search/aggs/buckets/index.ts @@ -35,3 +35,4 @@ export * from './lib/ip_range'; export * from './migrate_include_exclude_format'; export * from './significant_terms'; export * from './terms'; +export * from './lib/time_buckets/calc_auto_interval'; diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 9d85caa624e7..b3fe412152c9 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -196,6 +196,7 @@ import { includeTotalLoaded, toKibanaSearchResponse, getTotalLoaded, + calcAutoIntervalLessThan, } from '../common'; export { @@ -282,6 +283,7 @@ export const search = { siblingPipelineType, termsAggFilter, toAbsoluteDates, + calcAutoIntervalLessThan, }, getRequestInspectorStats, getResponseInspectorStats, diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 6583651e074c..6870ad5e2402 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -1084,6 +1084,7 @@ export const search: { siblingPipelineType: string; termsAggFilter: string[]; toAbsoluteDates: typeof toAbsoluteDates; + calcAutoIntervalLessThan: typeof calcAutoIntervalLessThan; }; getRequestInspectorStats: typeof getRequestInspectorStats; getResponseInspectorStats: typeof getResponseInspectorStats; @@ -1246,21 +1247,22 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:137:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:137:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:253:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:253:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:253:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:253:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:268:5 - (ae-forgotten-export) The symbol "getTotalLoaded" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:269:5 - (ae-forgotten-export) The symbol "toSnakeCase" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:273:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:274:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:283:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:284:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:285:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:289:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:290:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:294:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:297:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:269:5 - (ae-forgotten-export) The symbol "getTotalLoaded" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:270:5 - (ae-forgotten-export) The symbol "toSnakeCase" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:274:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:275:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:284:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:285:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:286:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:290:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:291:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:295:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:298:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:299:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index_patterns/index_patterns_service.ts:58:14 - (ae-forgotten-export) The symbol "IndexPatternsService" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:88:66 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts // src/plugins/data/server/search/types.ts:104:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/vis_type_timeseries/common/constants.ts b/src/plugins/vis_type_timeseries/common/constants.ts index 4f24bc273e26..bfcb5e8e15b9 100644 --- a/src/plugins/vis_type_timeseries/common/constants.ts +++ b/src/plugins/vis_type_timeseries/common/constants.ts @@ -19,7 +19,7 @@ export const MAX_BUCKETS_SETTING = 'metrics:max_buckets'; export const INDEXES_SEPARATOR = ','; - +export const AUTO_INTERVAL = 'auto'; export const ROUTES = { VIS_DATA: '/api/metrics/vis/data', }; diff --git a/src/plugins/vis_type_timeseries/common/vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts index 7f17a9c44298..a90fa752ad7d 100644 --- a/src/plugins/vis_type_timeseries/common/vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -175,6 +175,7 @@ export const seriesItems = schema.object({ separate_axis: numberIntegerOptional, seperate_axis: numberIntegerOptional, series_index_pattern: stringOptionalNullable, + series_max_bars: numberIntegerOptional, series_time_field: stringOptionalNullable, series_interval: stringOptionalNullable, series_drop_last_bucket: numberIntegerOptional, @@ -229,6 +230,7 @@ export const panel = schema.object({ ignore_global_filters: numberOptional, ignore_global_filter: numberOptional, index_pattern: stringRequired, + max_bars: numberIntegerOptional, interval: stringRequired, isModelInvalid: schema.maybe(schema.boolean()), legend_position: stringOptionalNullable, diff --git a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js index 85f31285df69..e976519dfe63 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js @@ -19,7 +19,7 @@ import { get } from 'lodash'; import PropTypes from 'prop-types'; -import React, { useContext } from 'react'; +import React, { useContext, useCallback } from 'react'; import { htmlIdGenerator, EuiFieldText, @@ -27,7 +27,10 @@ import { EuiFlexItem, EuiFormRow, EuiComboBox, + EuiRange, + EuiIconTip, EuiText, + EuiFormLabel, } from '@elastic/eui'; import { FieldSelect } from './aggs/field_select'; import { createSelectHandler } from './lib/create_select_handler'; @@ -35,19 +38,20 @@ import { createTextHandler } from './lib/create_text_handler'; import { YesNo } from './yes_no'; import { KBN_FIELD_TYPES } from '../../../../../plugins/data/public'; import { FormValidationContext } from '../contexts/form_validation_context'; -import { - isGteInterval, - validateReInterval, - isAutoInterval, - AUTO_INTERVAL, -} from './lib/get_interval'; +import { isGteInterval, validateReInterval, isAutoInterval } from './lib/get_interval'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { TIME_RANGE_DATA_MODES, TIME_RANGE_MODE_KEY } from '../../../common/timerange_data_modes'; import { PANEL_TYPES } from '../../../common/panel_types'; import { isTimerangeModeEnabled } from '../lib/check_ui_restrictions'; import { VisDataContext } from '../contexts/vis_data_context'; +import { getUISettings } from '../../services'; +import { AUTO_INTERVAL } from '../../../common/constants'; +import { UI_SETTINGS } from '../../../../data/common'; const RESTRICT_FIELDS = [KBN_FIELD_TYPES.DATE]; +const LEVEL_OF_DETAIL_STEPS = 10; +const LEVEL_OF_DETAIL_MIN_BUCKETS = 1; const validateIntervalValue = (intervalValue) => { const isAutoOrGteInterval = isGteInterval(intervalValue) || isAutoInterval(intervalValue); @@ -65,15 +69,36 @@ const htmlId = htmlIdGenerator(); const isEntireTimeRangeActive = (model, isTimeSeries) => !isTimeSeries && model[TIME_RANGE_MODE_KEY] === TIME_RANGE_DATA_MODES.ENTIRE_TIME_RANGE; -export const IndexPattern = ({ fields, prefix, onChange, disabled, model: _model }) => { +export const IndexPattern = ({ + fields, + prefix, + onChange, + disabled, + model: _model, + allowLevelofDetail, +}) => { + const config = getUISettings(); + const handleSelectChange = createSelectHandler(onChange); const handleTextChange = createTextHandler(onChange); + const timeFieldName = `${prefix}time_field`; const indexPatternName = `${prefix}index_pattern`; const intervalName = `${prefix}interval`; + const maxBarsName = `${prefix}max_bars`; const dropBucketName = `${prefix}drop_last_bucket`; const updateControlValidity = useContext(FormValidationContext); const uiRestrictions = get(useContext(VisDataContext), 'uiRestrictions'); + const maxBarsUiSettings = config.get(UI_SETTINGS.HISTOGRAM_MAX_BARS); + + const handleMaxBarsChange = useCallback( + ({ target }) => { + onChange({ + [maxBarsName]: Math.max(LEVEL_OF_DETAIL_MIN_BUCKETS, target.value), + }); + }, + [onChange, maxBarsName] + ); const timeRangeOptions = [ { @@ -97,10 +122,12 @@ export const IndexPattern = ({ fields, prefix, onChange, disabled, model: _model [indexPatternName]: '*', [intervalName]: AUTO_INTERVAL, [dropBucketName]: 1, + [maxBarsName]: config.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), [TIME_RANGE_MODE_KEY]: timeRangeOptions[0].value, }; const model = { ...defaults, ..._model }; + const isDefaultIndexPatternUsed = model.default_index_pattern && !model[indexPatternName]; const intervalValidation = validateIntervalValue(model[intervalName]); const selectedTimeRangeOption = timeRangeOptions.find( @@ -229,6 +256,77 @@ export const IndexPattern = ({ fields, prefix, onChange, disabled, model: _model + {allowLevelofDetail && ( + + + + {' '} + + } + type="questionInCircle" + /> + + } + > + + + + + + + + + + + + + + + + + + + )}
    ); }; @@ -245,4 +343,5 @@ IndexPattern.propTypes = { prefix: PropTypes.string, disabled: PropTypes.bool, className: PropTypes.string, + allowLevelofDetail: PropTypes.bool, }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_interval.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_interval.js index c1d484765f4c..f54d52620e67 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/get_interval.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_interval.js @@ -22,8 +22,7 @@ import { get } from 'lodash'; import { search } from '../../../../../../plugins/data/public'; const { parseEsInterval } = search.aggs; import { GTE_INTERVAL_RE } from '../../../../common/interval_regexp'; - -export const AUTO_INTERVAL = 'auto'; +import { AUTO_INTERVAL } from '../../../../common/constants'; export const unitLookup = { s: i18n.translate('visTypeTimeseries.getInterval.secondsLabel', { defaultMessage: 'seconds' }), diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js index 03da52b10f08..180411dd13a3 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js @@ -193,6 +193,7 @@ class TimeseriesPanelConfigUi extends Component { fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowLevelofDetail={true} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js index 9742d817f7c0..7893d5ba6d15 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor_visualization.js @@ -26,8 +26,8 @@ import { convertIntervalIntoUnit, isAutoInterval, isGteInterval, - AUTO_INTERVAL, } from './lib/get_interval'; +import { AUTO_INTERVAL } from '../../../common/constants'; import { PANEL_TYPES } from '../../../common/panel_types'; const MIN_CHART_HEIGHT = 300; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js index 59277257c0c9..25561cfe1dc0 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js @@ -554,7 +554,7 @@ export const TimeseriesConfig = injectI18n(function (props) { {...props} prefix="series_" disabled={!model.override_index_pattern} - with-interval={true} + allowLevelofDetail={true} /> diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.js index d11e9316c959..1b2334c7dea9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.js @@ -19,6 +19,7 @@ import { buildAnnotationRequest } from './build_request_body'; import { getEsShardTimeout } from '../helpers/get_es_shard_timeout'; import { getIndexPatternObject } from '../helpers/get_index_pattern'; +import { UI_SETTINGS } from '../../../../../data/common'; export async function getAnnotationRequestParams( req, @@ -27,6 +28,7 @@ export async function getAnnotationRequestParams( esQueryConfig, capabilities ) { + const uiSettings = req.getUiSettingsService(); const esShardTimeout = await getEsShardTimeout(req); const indexPattern = annotation.index_pattern; const { indexPatternObject, indexPatternString } = await getIndexPatternObject(req, indexPattern); @@ -36,7 +38,11 @@ export async function getAnnotationRequestParams( annotation, esQueryConfig, indexPatternObject, - capabilities + capabilities, + { + maxBarsUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + barTargetUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + } ); return { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js index 82a2ef66cb1c..9714b551ea82 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js @@ -17,6 +17,8 @@ * under the License. */ +import { AUTO_INTERVAL } from '../../../common/constants'; + const DEFAULT_TIME_FIELD = '@timestamp'; export function getIntervalAndTimefield(panel, series = {}, indexPatternObject) { @@ -26,10 +28,18 @@ export function getIntervalAndTimefield(panel, series = {}, indexPatternObject) (series.override_index_pattern && series.series_time_field) || panel.time_field || getDefaultTimeField(); - const interval = (series.override_index_pattern && series.series_interval) || panel.interval; + + let interval = panel.interval; + let maxBars = panel.max_bars; + + if (series.override_index_pattern) { + interval = series.series_interval; + maxBars = series.series_max_bars; + } return { timeField, - interval, + interval: interval || AUTO_INTERVAL, + maxBars, }; } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js index 3791eb229db5..eaaa5a9605b4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js @@ -22,6 +22,7 @@ import { get } from 'lodash'; import { processBucket } from './table/process_bucket'; import { getEsQueryConfig } from './helpers/get_es_query_uisettings'; import { getIndexPatternObject } from './helpers/get_index_pattern'; +import { UI_SETTINGS } from '../../../../data/common'; export async function getTableData(req, panel) { const panelIndexPattern = panel.index_pattern; @@ -39,7 +40,12 @@ export async function getTableData(req, panel) { }; try { - const body = buildRequestBody(req, panel, esQueryConfig, indexPatternObject, capabilities); + const uiSettings = req.getUiSettingsService(); + const body = buildRequestBody(req, panel, esQueryConfig, indexPatternObject, capabilities, { + maxBarsUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + barTargetUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + }); + const [resp] = await searchStrategy.search(req, [ { body, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/calculate_auto.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/calculate_auto.js deleted file mode 100644 index 0c3555adff1a..000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/calculate_auto.js +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import moment from 'moment'; -const d = moment.duration; - -const roundingRules = [ - [d(500, 'ms'), d(100, 'ms')], - [d(5, 'second'), d(1, 'second')], - [d(7.5, 'second'), d(5, 'second')], - [d(15, 'second'), d(10, 'second')], - [d(45, 'second'), d(30, 'second')], - [d(3, 'minute'), d(1, 'minute')], - [d(9, 'minute'), d(5, 'minute')], - [d(20, 'minute'), d(10, 'minute')], - [d(45, 'minute'), d(30, 'minute')], - [d(2, 'hour'), d(1, 'hour')], - [d(6, 'hour'), d(3, 'hour')], - [d(24, 'hour'), d(12, 'hour')], - [d(1, 'week'), d(1, 'd')], - [d(3, 'week'), d(1, 'week')], - [d(1, 'year'), d(1, 'month')], - [Infinity, d(1, 'year')], -]; - -const revRoundingRules = roundingRules.slice(0).reverse(); - -function find(rules, check, last) { - function pick(buckets, duration) { - const target = duration / buckets; - let lastResp = null; - - for (let i = 0; i < rules.length; i++) { - const rule = rules[i]; - const resp = check(rule[0], rule[1], target); - - if (resp == null) { - if (!last) continue; - if (lastResp) return lastResp; - break; - } - - if (!last) return resp; - lastResp = resp; - } - - // fallback to just a number of milliseconds, ensure ms is >= 1 - const ms = Math.max(Math.floor(target), 1); - return moment.duration(ms, 'ms'); - } - - return (buckets, duration) => { - const interval = pick(buckets, duration); - if (interval) return moment.duration(interval._data); - }; -} - -export const calculateAuto = { - near: find( - revRoundingRules, - function near(bound, interval, target) { - if (bound > target) return interval; - }, - true - ), - - lessThan: find(revRoundingRules, function lessThan(_bound, interval, target) { - if (interval < target) return interval; - }), - - atLeast: find(revRoundingRules, function atLeast(_bound, interval, target) { - if (interval <= target) return interval; - }), -}; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js index c021ba3cebc6..4384da58fb56 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.js @@ -17,15 +17,15 @@ * under the License. */ -import { calculateAuto } from './calculate_auto'; import { getUnitValue, parseInterval, convertIntervalToUnit, ASCENDING_UNIT_ORDER, } from './unit_to_seconds'; -import { getTimerangeDuration } from './get_timerange'; +import { getTimerange } from './get_timerange'; import { INTERVAL_STRING_RE, GTE_INTERVAL_RE } from '../../../../common/interval_regexp'; +import { search } from '../../../../../data/server'; const calculateBucketData = (timeInterval, capabilities) => { let intervalString = capabilities @@ -65,14 +65,15 @@ const calculateBucketData = (timeInterval, capabilities) => { }; }; -const calculateBucketSizeForAutoInterval = (req) => { - const duration = getTimerangeDuration(req); +const calculateBucketSizeForAutoInterval = (req, maxBars) => { + const { from, to } = getTimerange(req); + const timerange = to.valueOf() - from.valueOf(); - return calculateAuto.near(100, duration).asSeconds(); + return search.aggs.calcAutoIntervalLessThan(maxBars, timerange).asSeconds(); }; -export const getBucketSize = (req, interval, capabilities) => { - const bucketSize = calculateBucketSizeForAutoInterval(req); +export const getBucketSize = (req, interval, capabilities, maxBars) => { + const bucketSize = calculateBucketSizeForAutoInterval(req, maxBars); let intervalString = `${bucketSize}s`; const gteAutoMatch = Boolean(interval) && interval.match(GTE_INTERVAL_RE); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.js index 99bef2de6b72..8810ccd406be 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.js @@ -30,37 +30,43 @@ describe('getBucketSize', () => { }; test('returns auto calculated buckets', () => { - const result = getBucketSize(req, 'auto'); + const result = getBucketSize(req, 'auto', undefined, 100); + expect(result).toHaveProperty('bucketSize', 30); expect(result).toHaveProperty('intervalString', '30s'); }); test('returns overridden buckets (1s)', () => { - const result = getBucketSize(req, '1s'); + const result = getBucketSize(req, '1s', undefined, 100); + expect(result).toHaveProperty('bucketSize', 1); expect(result).toHaveProperty('intervalString', '1s'); }); test('returns overridden buckets (10m)', () => { - const result = getBucketSize(req, '10m'); + const result = getBucketSize(req, '10m', undefined, 100); + expect(result).toHaveProperty('bucketSize', 600); expect(result).toHaveProperty('intervalString', '10m'); }); test('returns overridden buckets (1d)', () => { - const result = getBucketSize(req, '1d'); + const result = getBucketSize(req, '1d', undefined, 100); + expect(result).toHaveProperty('bucketSize', 86400); expect(result).toHaveProperty('intervalString', '1d'); }); test('returns overridden buckets (>=2d)', () => { - const result = getBucketSize(req, '>=2d'); + const result = getBucketSize(req, '>=2d', undefined, 100); + expect(result).toHaveProperty('bucketSize', 86400 * 2); expect(result).toHaveProperty('intervalString', '2d'); }); test('returns overridden buckets (>=10s)', () => { - const result = getBucketSize(req, '>=10s'); + const result = getBucketSize(req, '>=10s', undefined, 100); + expect(result).toHaveProperty('bucketSize', 30); expect(result).toHaveProperty('intervalString', '30s'); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts similarity index 92% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.js rename to src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts index 1a1b12c65199..183ce50dd4a0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts @@ -17,20 +17,22 @@ * under the License. */ -import { getTimerange } from './get_timerange'; import moment from 'moment'; +import { getTimerange } from './get_timerange'; +import { ReqFacade, VisPayload } from '../../..'; describe('getTimerange(req)', () => { test('should return a moment object for to and from', () => { - const req = { + const req = ({ payload: { timerange: { min: '2017-01-01T00:00:00Z', max: '2017-01-01T01:00:00Z', }, }, - }; + } as unknown) as ReqFacade; const { from, to } = getTimerange(req); + expect(moment.isMoment(from)).toEqual(true); expect(moment.isMoment(to)).toEqual(true); expect(moment.utc('2017-01-01T00:00:00Z').isSame(from)).toEqual(true); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts similarity index 75% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.js rename to src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts index 682befe9ab05..54f3110b4580 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts @@ -17,19 +17,14 @@ * under the License. */ -import moment from 'moment'; +import { utc } from 'moment'; +import { ReqFacade, VisPayload } from '../../..'; -export const getTimerange = (req) => { +export const getTimerange = (req: ReqFacade) => { const { min, max } = req.payload.timerange; return { - from: moment.utc(min), - to: moment.utc(max), + from: utc(min), + to: utc(max), }; }; - -export const getTimerangeDuration = (req) => { - const { from, to } = getTimerange(req); - - return moment.duration(to.valueOf() - from.valueOf(), 'ms'); -}; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js index 4b611e46f158..617a75f6bd59 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -29,11 +29,17 @@ export function dateHistogram( annotation, esQueryConfig, indexPatternObject, - capabilities + capabilities, + { barTargetUiSettings } ) { return (next) => (doc) => { const timeField = annotation.time_field; - const { bucketSize, intervalString } = getBucketSize(req, 'auto', capabilities); + const { bucketSize, intervalString } = getBucketSize( + req, + 'auto', + capabilities, + barTargetUiSettings + ); const { from, to } = getTimerange(req); const timezone = capabilities.searchTimezone; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js index 127687bf11fe..cf02f601ea5f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/query.js @@ -21,10 +21,18 @@ import { getBucketSize } from '../../helpers/get_bucket_size'; import { getTimerange } from '../../helpers/get_timerange'; import { esQuery } from '../../../../../../data/server'; -export function query(req, panel, annotation, esQueryConfig, indexPattern, capabilities) { +export function query( + req, + panel, + annotation, + esQueryConfig, + indexPattern, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const timeField = annotation.time_field; - const { bucketSize } = getBucketSize(req, 'auto', capabilities); + const { bucketSize } = getBucketSize(req, 'auto', capabilities, barTargetUiSettings); const { from, to } = getTimerange(req); doc.size = 0; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index f1e58b8e4af2..98c683bda1fd 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -25,10 +25,27 @@ import { isLastValueTimerangeMode } from '../../helpers/get_timerange_mode'; import { search } from '../../../../../../../plugins/data/server'; const { dateHistogramInterval } = search.aggs; -export function dateHistogram(req, panel, series, esQueryConfig, indexPatternObject, capabilities) { +export function dateHistogram( + req, + panel, + series, + esQueryConfig, + indexPatternObject, + capabilities, + { maxBarsUiSettings, barTargetUiSettings } +) { return (next) => (doc) => { - const { timeField, interval } = getIntervalAndTimefield(panel, series, indexPatternObject); - const { bucketSize, intervalString } = getBucketSize(req, interval, capabilities); + const { timeField, interval, maxBars } = getIntervalAndTimefield( + panel, + series, + indexPatternObject + ); + const { bucketSize, intervalString } = getBucketSize( + req, + interval, + capabilities, + maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings + ); const getDateHistogramForLastBucketMode = () => { const { from, to } = offsetTime(req, series.offset_time); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index 45cad1195fc7..aa95a79a6279 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -27,6 +27,7 @@ describe('dateHistogram(req, panel, series)', () => { let capabilities; let config; let indexPatternObject; + let uiSettings; beforeEach(() => { req = { @@ -50,19 +51,29 @@ describe('dateHistogram(req, panel, series)', () => { }; indexPatternObject = {}; capabilities = new DefaultSearchCapabilities(req); + uiSettings = { maxBarsUiSettings: 100, barTargetUiSettings: 50 }; }); test('calls next when finished', () => { const next = jest.fn(); - dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)({}); + dateHistogram(req, panel, series, config, indexPatternObject, capabilities, uiSettings)(next)( + {} + ); expect(next.mock.calls.length).toEqual(1); }); test('returns valid date histogram', () => { const next = (doc) => doc; - const doc = dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)( - {} - ); + const doc = dateHistogram( + req, + panel, + series, + config, + indexPatternObject, + capabilities, + uiSettings + )(next)({}); + expect(doc).toEqual({ aggs: { test: { @@ -94,9 +105,16 @@ describe('dateHistogram(req, panel, series)', () => { test('returns valid date histogram (offset by 1h)', () => { series.offset_time = '1h'; const next = (doc) => doc; - const doc = dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)( - {} - ); + const doc = dateHistogram( + req, + panel, + series, + config, + indexPatternObject, + capabilities, + uiSettings + )(next)({}); + expect(doc).toEqual({ aggs: { test: { @@ -131,9 +149,16 @@ describe('dateHistogram(req, panel, series)', () => { series.series_time_field = 'timestamp'; series.series_interval = '20s'; const next = (doc) => doc; - const doc = dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)( - {} - ); + const doc = dateHistogram( + req, + panel, + series, + config, + indexPatternObject, + capabilities, + uiSettings + )(next)({}); + expect(doc).toEqual({ aggs: { test: { @@ -168,9 +193,15 @@ describe('dateHistogram(req, panel, series)', () => { panel.type = 'timeseries'; const next = (doc) => doc; - const doc = dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)( - {} - ); + const doc = dateHistogram( + req, + panel, + series, + config, + indexPatternObject, + capabilities, + uiSettings + )(next)({}); expect(doc.aggs.test.aggs.timeseries.auto_date_histogram).toBeUndefined(); expect(doc.aggs.test.aggs.timeseries.date_histogram).toBeDefined(); @@ -180,9 +211,16 @@ describe('dateHistogram(req, panel, series)', () => { panel.time_range_mode = 'entire_time_range'; const next = (doc) => doc; - const doc = dateHistogram(req, panel, series, config, indexPatternObject, capabilities)(next)( - {} - ); + const doc = dateHistogram( + req, + panel, + series, + config, + indexPatternObject, + capabilities, + uiSettings + )(next)({}); + expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 800145dac546..023ee054a5e1 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -21,10 +21,19 @@ import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; -export function metricBuckets(req, panel, series, esQueryConfig, indexPatternObject, capabilities) { +export function metricBuckets( + req, + panel, + series, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); - const { intervalString } = getBucketSize(req, interval, capabilities); + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + series.metrics .filter((row) => !/_bucket$/.test(row.type) && !/^series/.test(row.type)) .forEach((metric) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.test.js index 1ac4329b60f8..2154d2257815 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.test.js @@ -20,56 +20,64 @@ import { metricBuckets } from './metric_buckets'; describe('metricBuckets(req, panel, series)', () => { - let panel; - let series; - let req; + let metricBucketsProcessor; + beforeEach(() => { - panel = { - time_field: 'timestamp', - }; - series = { - id: 'test', - split_mode: 'terms', - terms_size: 10, - terms_field: 'host', - metrics: [ - { - id: 'metric-1', - type: 'max', - field: 'io', - }, - { - id: 'metric-2', - type: 'derivative', - field: 'metric-1', - unit: '1s', - }, - { - id: 'metric-3', - type: 'avg_bucket', - field: 'metric-2', - }, - ], - }; - req = { - payload: { - timerange: { - min: '2017-01-01T00:00:00Z', - max: '2017-01-01T01:00:00Z', + metricBucketsProcessor = metricBuckets( + { + payload: { + timerange: { + min: '2017-01-01T00:00:00Z', + max: '2017-01-01T01:00:00Z', + }, }, }, - }; + { + time_field: 'timestamp', + }, + { + id: 'test', + split_mode: 'terms', + terms_size: 10, + terms_field: 'host', + metrics: [ + { + id: 'metric-1', + type: 'max', + field: 'io', + }, + { + id: 'metric-2', + type: 'derivative', + field: 'metric-1', + unit: '1s', + }, + { + id: 'metric-3', + type: 'avg_bucket', + field: 'metric-2', + }, + ], + }, + {}, + {}, + undefined, + { + barTargetUiSettings: 50, + } + ); }); test('calls next when finished', () => { const next = jest.fn(); - metricBuckets(req, panel, series)(next)({}); + metricBucketsProcessor(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns metric aggs', () => { const next = (doc) => doc; - const doc = metricBuckets(req, panel, series)(next)({}); + const doc = metricBucketsProcessor(next)({}); + expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js index 4a79ec229587..c16e0fd3aaf1 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js @@ -57,10 +57,19 @@ export const createPositiveRate = (doc, intervalString, aggRoot) => (metric) => overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, positiveOnlyBucket); }; -export function positiveRate(req, panel, series, esQueryConfig, indexPatternObject, capabilities) { +export function positiveRate( + req, + panel, + series, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); - const { intervalString } = getBucketSize(req, interval, capabilities); + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + if (series.metrics.some(filter)) { series.metrics .filter(filter) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js index 7c0f43adf02f..d891fc01bb26 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.test.js @@ -22,6 +22,8 @@ describe('positiveRate(req, panel, series)', () => { let panel; let series; let req; + let uiSettings; + beforeEach(() => { panel = { time_field: 'timestamp', @@ -48,17 +50,20 @@ describe('positiveRate(req, panel, series)', () => { }, }, }; + uiSettings = { + barTargetUiSettings: 50, + }; }); test('calls next when finished', () => { const next = jest.fn(); - positiveRate(req, panel, series)(next)({}); + positiveRate(req, panel, series, {}, {}, undefined, uiSettings)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns positive rate aggs', () => { const next = (doc) => doc; - const doc = positiveRate(req, panel, series)(next)({}); + const doc = positiveRate(req, panel, series, {}, {}, undefined, uiSettings)(next)({}); expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index f2b58822e68b..f69473b613d1 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -28,11 +28,13 @@ export function siblingBuckets( series, esQueryConfig, indexPatternObject, - capabilities + capabilities, + { barTargetUiSettings } ) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); - const { bucketSize } = getBucketSize(req, interval, capabilities); + const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + series.metrics .filter((row) => /_bucket$/.test(row.type)) .forEach((metric) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.test.js index 8f84023ce0c7..48714e83341e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.test.js @@ -23,6 +23,8 @@ describe('siblingBuckets(req, panel, series)', () => { let panel; let series; let req; + let uiSettings; + beforeEach(() => { panel = { time_field: 'timestamp', @@ -53,17 +55,21 @@ describe('siblingBuckets(req, panel, series)', () => { }, }, }; + uiSettings = { + barTargetUiSettings: 50, + }; }); test('calls next when finished', () => { const next = jest.fn(); - siblingBuckets(req, panel, series)(next)({}); + siblingBuckets(req, panel, series, {}, {}, undefined, uiSettings)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns sibling aggs', () => { const next = (doc) => doc; - const doc = siblingBuckets(req, panel, series)(next)({}); + const doc = siblingBuckets(req, panel, series, {}, {}, undefined, uiSettings)(next)({}); + expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 947e48ed2cab..ba65e583cc09 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -26,7 +26,14 @@ import { calculateAggRoot } from './calculate_agg_root'; import { search } from '../../../../../../../plugins/data/server'; const { dateHistogramInterval } = search.aggs; -export function dateHistogram(req, panel, esQueryConfig, indexPatternObject, capabilities) { +export function dateHistogram( + req, + panel, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { timeField, interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); const meta = { @@ -34,7 +41,12 @@ export function dateHistogram(req, panel, esQueryConfig, indexPatternObject, cap }; const getDateHistogramForLastBucketMode = () => { - const { bucketSize, intervalString } = getBucketSize(req, interval, capabilities); + const { bucketSize, intervalString } = getBucketSize( + req, + interval, + capabilities, + barTargetUiSettings + ); const { from, to } = getTimerange(req); const timezone = capabilities.searchTimezone; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index ba2c09e93e7e..fe6a8b537d64 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -23,10 +23,18 @@ import { bucketTransform } from '../../helpers/bucket_transform'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -export function metricBuckets(req, panel, esQueryConfig, indexPatternObject) { +export function metricBuckets( + req, + panel, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); - const { intervalString } = getBucketSize(req, interval); + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js index b219f84deef8..6cf165d124e2 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js @@ -22,10 +22,18 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; import { createPositiveRate, filter } from '../series/positive_rate'; -export function positiveRate(req, panel, esQueryConfig, indexPatternObject) { +export function positiveRate( + req, + panel, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); - const { intervalString } = getBucketSize(req, interval); + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics.filter(filter).forEach(createPositiveRate(doc, intervalString, aggRoot)); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index 1b14ffe34a94..ba08b18256de 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -23,10 +23,18 @@ import { bucketTransform } from '../../helpers/bucket_transform'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -export function siblingBuckets(req, panel, esQueryConfig, indexPatternObject) { +export function siblingBuckets( + req, + panel, + esQueryConfig, + indexPatternObject, + capabilities, + { barTargetUiSettings } +) { return (next) => (doc) => { const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); - const { bucketSize } = getBucketSize(req, interval); + const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); + panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts index 0c75e6ef1c5b..6b2ef320d54b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts @@ -97,7 +97,8 @@ describe('buildRequestBody(req)', () => { series, config, indexPatternObject, - capabilities + capabilities, + { barTargetUiSettings: 50 } ); expect(doc).toEqual({ diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.js index 4c653ea49e7c..3804b1407b08 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.js @@ -19,18 +19,25 @@ import { buildRequestBody } from './build_request_body'; import { getEsShardTimeout } from '../helpers/get_es_shard_timeout'; import { getIndexPatternObject } from '../helpers/get_index_pattern'; +import { UI_SETTINGS } from '../../../../../data/common'; export async function getSeriesRequestParams(req, panel, series, esQueryConfig, capabilities) { + const uiSettings = req.getUiSettingsService(); const indexPattern = (series.override_index_pattern && series.series_index_pattern) || panel.index_pattern; const { indexPatternObject, indexPatternString } = await getIndexPatternObject(req, indexPattern); + const request = buildRequestBody( req, panel, series, esQueryConfig, indexPatternObject, - capabilities + capabilities, + { + maxBarsUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + barTargetUiSettings: await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + } ); const esShardTimeout = await getEsShardTimeout(req); From e892b03173a83ebb001f228c2215b160012b9667 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 24 Nov 2020 08:24:26 -0800 Subject: [PATCH 29/89] [build] Provide ARM build of RE2 (#84163) Signed-off-by: Tyler Smalley --- .../build/tasks/patch_native_modules_task.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/dev/build/tasks/patch_native_modules_task.ts b/src/dev/build/tasks/patch_native_modules_task.ts index c3011fa80988..b6eda2dbfd56 100644 --- a/src/dev/build/tasks/patch_native_modules_task.ts +++ b/src/dev/build/tasks/patch_native_modules_task.ts @@ -46,15 +46,30 @@ const packages: Package[] = [ destinationPath: 'node_modules/re2/build/Release/re2.node', extractMethod: 'gunzip', archives: { - darwin: { + 'darwin-x64': { url: 'https://github.com/uhop/node-re2/releases/download/1.15.4/darwin-x64-72.gz', sha256: '983106049bb86e21b7f823144b2b83e3f1408217401879b3cde0312c803512c9', }, - linux: { + 'linux-x64': { url: 'https://github.com/uhop/node-re2/releases/download/1.15.4/linux-x64-72.gz', sha256: '8b6692037f7b0df24dabc9c9b039038d1c3a3110f62121616b406c482169710a', }, - win32: { + + // ARM build is currently done manually as Github Actions used in upstream project + // do not natively support an ARM target. + + // From a AWS Graviton instance: + // * checkout the node-re2 project, + // * install Node using the same minor used by Kibana + // * npm install, which will also create a build + // * gzip -c build/Release/re2.node > linux-arm64-72.gz + // * upload to kibana-ci-proxy-cache bucket + 'linux-arm64': { + url: + 'https://storage.googleapis.com/kibana-ci-proxy-cache/node-re2/uhop/node-re2/releases/download/1.15.4/linux-arm64-72.gz', + sha256: '5942353ec9cf46a39199818d474f7af137cfbb1bc5727047fe22f31f36602a7e', + }, + 'win32-x64': { url: 'https://github.com/uhop/node-re2/releases/download/1.15.4/win32-x64-72.gz', sha256: '0a6991e693577160c3e9a3f196bd2518368c52d920af331a1a183313e0175604', }, @@ -84,7 +99,7 @@ async function patchModule( `Can't patch ${pkg.name}'s native module, we were expecting version ${pkg.version} and found ${installedVersion}` ); } - const platformName = platform.getName(); + const platformName = platform.getNodeArch(); const archive = pkg.archives[platformName]; const archiveName = path.basename(archive.url); const downloadPath = config.resolveFromRepo(DOWNLOAD_DIRECTORY, pkg.name, archiveName); From 24f262b9ca74f8e3e219ea417c2cd3889696f08c Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 24 Nov 2020 16:29:32 +0000 Subject: [PATCH 30/89] [ML] Space permision checks for job deletion (#83871) * [ML] Space permision checks for job deletion * updating spaces dependency * updating endpoint comments * adding delete job capabilities check * small change based on review * improving permissions checks * renaming function and endpoint Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/ml/common/types/capabilities.ts | 2 +- .../plugins/ml/common/types/saved_objects.ts | 9 ++ x-pack/plugins/ml/server/lib/spaces_utils.ts | 24 +++- .../models/data_recognizer/data_recognizer.ts | 4 +- x-pack/plugins/ml/server/plugin.ts | 5 +- x-pack/plugins/ml/server/routes/apidoc.json | 1 + .../plugins/ml/server/routes/saved_objects.ts | 56 ++++++++- .../ml/server/routes/schemas/saved_objects.ts | 4 + .../ml/server/saved_objects/authorization.ts | 3 +- .../plugins/ml/server/saved_objects/checks.ts | 108 +++++++++++++++++- .../ml/server/saved_objects/service.ts | 17 ++- x-pack/plugins/ml/server/types.ts | 5 + 12 files changed, 223 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index d708cd56b78d..91020eee2660 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -123,7 +123,7 @@ export function getPluginPrivileges() { catalogue: [], savedObject: { all: [], - read: ['ml-job'], + read: [ML_SAVED_OBJECT_TYPE], }, api: apmUserMlCapabilitiesKeys.map((k) => `ml:${k}`), ui: apmUserMlCapabilitiesKeys, diff --git a/x-pack/plugins/ml/common/types/saved_objects.ts b/x-pack/plugins/ml/common/types/saved_objects.ts index 9f4d402ec175..d6c9ad758e8c 100644 --- a/x-pack/plugins/ml/common/types/saved_objects.ts +++ b/x-pack/plugins/ml/common/types/saved_objects.ts @@ -27,3 +27,12 @@ export interface InitializeSavedObjectResponse { success: boolean; error?: any; } + +export interface DeleteJobCheckResponse { + [jobId: string]: DeleteJobPermission; +} + +export interface DeleteJobPermission { + canDelete: boolean; + canUntag: boolean; +} diff --git a/x-pack/plugins/ml/server/lib/spaces_utils.ts b/x-pack/plugins/ml/server/lib/spaces_utils.ts index b96fe6f2d1eb..ecff3b8124cf 100644 --- a/x-pack/plugins/ml/server/lib/spaces_utils.ts +++ b/x-pack/plugins/ml/server/lib/spaces_utils.ts @@ -7,6 +7,7 @@ import { Legacy } from 'kibana'; import { KibanaRequest } from '../../../../../src/core/server'; import { SpacesPluginStart } from '../../../spaces/server'; +import { PLUGIN_ID } from '../../common/constants/app'; export type RequestFacade = KibanaRequest | Legacy.Request; @@ -22,19 +23,34 @@ export function spacesUtilsProvider( const space = await (await getSpacesPlugin()).spacesService.getActiveSpace( request instanceof KibanaRequest ? request : KibanaRequest.from(request) ); - return space.disabledFeatures.includes('ml') === false; + return space.disabledFeatures.includes(PLUGIN_ID) === false; } - async function getAllSpaces(): Promise { + async function getAllSpaces() { if (getSpacesPlugin === undefined) { return null; } const client = (await getSpacesPlugin()).spacesService.createSpacesClient( request instanceof KibanaRequest ? request : KibanaRequest.from(request) ); - const spaces = await client.getAll(); + return await client.getAll(); + } + + async function getAllSpaceIds(): Promise { + const spaces = await getAllSpaces(); + if (spaces === null) { + return null; + } return spaces.map((s) => s.id); } - return { isMlEnabledInSpace, getAllSpaces }; + async function getMlSpaceIds(): Promise { + const spaces = await getAllSpaces(); + if (spaces === null) { + return null; + } + return spaces.filter((s) => s.disabledFeatures.includes(PLUGIN_ID) === false).map((s) => s.id); + } + + return { isMlEnabledInSpace, getAllSpaces, getAllSpaceIds, getMlSpaceIds }; } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index f875788d50c5..aeaf13ebf954 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -1095,7 +1095,9 @@ export class DataRecognizer { job.config.analysis_limits.model_memory_limit = modelMemoryLimit; } } catch (error) { - mlLog.warn(`Data recognizer could not estimate model memory limit ${error.body}`); + mlLog.warn( + `Data recognizer could not estimate model memory limit ${JSON.stringify(error.body)}` + ); } } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 5e103dbc1806..e48983c1c536 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -178,7 +178,10 @@ export class MlServerPlugin notificationRoutes(routeInit); resultsServiceRoutes(routeInit); jobValidationRoutes(routeInit, this.version); - savedObjectsRoutes(routeInit); + savedObjectsRoutes(routeInit, { + getSpaces, + resolveMlCapabilities, + }); systemRoutes(routeInit, { getSpaces, cloud: plugins.cloud, diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index c157ae9e8200..5672824f3d04 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -150,6 +150,7 @@ "AssignJobsToSpaces", "RemoveJobsFromSpaces", "JobsSpaces", + "DeleteJobCheck", "TrainedModels", "GetTrainedModel", diff --git a/x-pack/plugins/ml/server/routes/saved_objects.ts b/x-pack/plugins/ml/server/routes/saved_objects.ts index 1c9c975b6626..3ba69b0d6b50 100644 --- a/x-pack/plugins/ml/server/routes/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/saved_objects.ts @@ -5,14 +5,18 @@ */ import { wrapError } from '../client/error_wrapper'; -import { RouteInitialization } from '../types'; +import { RouteInitialization, SavedObjectsRouteDeps } from '../types'; import { checksFactory, repairFactory } from '../saved_objects'; -import { jobsAndSpaces, repairJobObjects } from './schemas/saved_objects'; +import { jobsAndSpaces, repairJobObjects, jobTypeSchema } from './schemas/saved_objects'; +import { jobIdsSchema } from './schemas/job_service_schema'; /** * Routes for job saved object management */ -export function savedObjectsRoutes({ router, routeGuard }: RouteInitialization) { +export function savedObjectsRoutes( + { router, routeGuard }: RouteInitialization, + { getSpaces, resolveMlCapabilities }: SavedObjectsRouteDeps +) { /** * @apiGroup JobSavedObjects * @@ -220,4 +224,50 @@ export function savedObjectsRoutes({ router, routeGuard }: RouteInitialization) } }) ); + + /** + * @apiGroup JobSavedObjects + * + * @api {get} /api/ml/saved_objects/delete_job_check Check whether user can delete a job + * @apiName DeleteJobCheck + * @apiDescription Check the user's ability to delete jobs. Returns whether they are able + * to fully delete the job and whether they are able to remove it from + * the current space. + * + * @apiSchema (body) jobIdsSchema (params) jobTypeSchema + * + */ + router.post( + { + path: '/api/ml/saved_objects/can_delete_job/{jobType}', + validate: { + params: jobTypeSchema, + body: jobIdsSchema, + }, + options: { + tags: ['access:ml:canGetJobs', 'access:ml:canGetDataFrameAnalytics'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService, client }) => { + try { + const { jobType } = request.params; + const { jobIds }: { jobIds: string[] } = request.body; + + const { canDeleteJobs } = checksFactory(client, jobSavedObjectService); + const body = await canDeleteJobs( + request, + jobType, + jobIds, + getSpaces !== undefined, + resolveMlCapabilities + ); + + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); } diff --git a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts index d7385f6468f4..6b8c64714a82 100644 --- a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts @@ -13,3 +13,7 @@ export const jobsAndSpaces = schema.object({ }); export const repairJobObjects = schema.object({ simulate: schema.maybe(schema.boolean()) }); + +export const jobTypeSchema = schema.object({ + jobType: schema.string(), +}); diff --git a/x-pack/plugins/ml/server/saved_objects/authorization.ts b/x-pack/plugins/ml/server/saved_objects/authorization.ts index 815ff29ae010..958ee2091f11 100644 --- a/x-pack/plugins/ml/server/saved_objects/authorization.ts +++ b/x-pack/plugins/ml/server/saved_objects/authorization.ts @@ -6,6 +6,7 @@ import { KibanaRequest } from 'kibana/server'; import type { SecurityPluginSetup } from '../../../security/server'; +import { ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; export function authorizationProvider(authorization: SecurityPluginSetup['authz']) { async function authorizationCheck(request: KibanaRequest) { @@ -18,7 +19,7 @@ export function authorizationProvider(authorization: SecurityPluginSetup['authz' request ); const createMLJobAuthorizationAction = authorization.actions.savedObject.get( - 'ml-job', + ML_SAVED_OBJECT_TYPE, 'create' ); const canCreateGlobally = ( diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index 51269599105d..f682999cd596 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IScopedClusterClient } from 'kibana/server'; +import Boom from '@hapi/boom'; +import { IScopedClusterClient, KibanaRequest } from 'kibana/server'; import type { JobSavedObjectService } from './service'; -import { JobType } from '../../common/types/saved_objects'; +import { JobType, DeleteJobCheckResponse } from '../../common/types/saved_objects'; import { Job } from '../../common/types/anomaly_detection_jobs'; import { Datafeed } from '../../common/types/anomaly_detection_jobs'; import { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics'; +import { ResolveMlCapabilities } from '../../common/types/capabilities'; interface JobSavedObjectStatus { jobId: string; @@ -154,5 +156,105 @@ export function checksFactory( }; } - return { checkStatus }; + async function canDeleteJobs( + request: KibanaRequest, + jobType: JobType, + jobIds: string[], + spacesEnabled: boolean, + resolveMlCapabilities: ResolveMlCapabilities + ) { + if (jobType !== 'anomaly-detector' && jobType !== 'data-frame-analytics') { + throw Boom.badRequest('Job type must be "anomaly-detector" or "data-frame-analytics"'); + } + + const mlCapabilities = await resolveMlCapabilities(request); + if (mlCapabilities === null) { + throw Boom.internal('mlCapabilities is not defined'); + } + + if ( + (jobType === 'anomaly-detector' && mlCapabilities.canDeleteJob === false) || + (jobType === 'data-frame-analytics' && mlCapabilities.canDeleteDataFrameAnalytics === false) + ) { + // user does not have access to delete jobs. + return jobIds.reduce((results, jobId) => { + results[jobId] = { + canDelete: false, + canUntag: false, + }; + return results; + }, {} as DeleteJobCheckResponse); + } + + if (spacesEnabled === false) { + // spaces are disabled, delete only no untagging + return jobIds.reduce((results, jobId) => { + results[jobId] = { + canDelete: true, + canUntag: false, + }; + return results; + }, {} as DeleteJobCheckResponse); + } + const canCreateGlobalJobs = await jobSavedObjectService.canCreateGlobalJobs(request); + + const jobObjects = await Promise.all( + jobIds.map((id) => jobSavedObjectService.getJobObject(jobType, id)) + ); + + return jobIds.reduce((results, jobId) => { + const jobObject = jobObjects.find((j) => j?.attributes.job_id === jobId); + if (jobObject === undefined || jobObject.namespaces === undefined) { + // job saved object not found + results[jobId] = { + canDelete: false, + canUntag: false, + }; + return results; + } + + const { namespaces } = jobObject; + const isGlobalJob = namespaces.includes('*'); + + // job is in * space, user can see all spaces - delete and no option to untag + if (canCreateGlobalJobs && isGlobalJob) { + results[jobId] = { + canDelete: true, + canUntag: false, + }; + return results; + } + + // job is in * space, user cannot see all spaces - no untagging, no deleting + if (isGlobalJob) { + results[jobId] = { + canDelete: false, + canUntag: false, + }; + return results; + } + + // jobs with are in individual spaces can only be untagged + // from current space if the job is in more than 1 space + const canUntag = namespaces.length > 1; + + // job is in individual spaces, user cannot see all of them - untag only, no delete + if (namespaces.includes('?')) { + results[jobId] = { + canDelete: false, + canUntag, + }; + return results; + } + + // job is individual spaces, user can see all of them - delete and option to untag + results[jobId] = { + canDelete: true, + canUntag, + }; + return results; + }, {} as DeleteJobCheckResponse); + } + + return { checkStatus, canDeleteJobs }; } diff --git a/x-pack/plugins/ml/server/saved_objects/service.ts b/x-pack/plugins/ml/server/saved_objects/service.ts index ecaf0869d196..bfc5b165fe55 100644 --- a/x-pack/plugins/ml/server/saved_objects/service.ts +++ b/x-pack/plugins/ml/server/saved_objects/service.ts @@ -5,7 +5,12 @@ */ import RE2 from 're2'; -import { KibanaRequest, SavedObjectsClientContract, SavedObjectsFindOptions } from 'kibana/server'; +import { + KibanaRequest, + SavedObjectsClientContract, + SavedObjectsFindOptions, + SavedObjectsFindResult, +} from 'kibana/server'; import type { SecurityPluginSetup } from '../../../security/server'; import { JobType, ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; import { MLJobNotFound } from '../lib/ml_client'; @@ -133,6 +138,15 @@ export function jobSavedObjectServiceFactory( return await _getJobObjects(jobType, undefined, undefined, currentSpaceOnly); } + async function getJobObject( + jobType: JobType, + jobId: string, + currentSpaceOnly: boolean = true + ): Promise | undefined> { + const [jobObject] = await _getJobObjects(jobType, jobId, undefined, currentSpaceOnly); + return jobObject; + } + async function getAllJobObjectsForAllSpaces(jobType?: JobType) { await isMlReady(); const filterObject: JobObjectFilter = {}; @@ -307,6 +321,7 @@ export function jobSavedObjectServiceFactory( return { getAllJobObjects, + getJobObject, createAnomalyDetectionJob, createDataFrameAnalyticsJob, deleteAnomalyDetectionJob, diff --git a/x-pack/plugins/ml/server/types.ts b/x-pack/plugins/ml/server/types.ts index df40f5a26b0f..780a4284312e 100644 --- a/x-pack/plugins/ml/server/types.ts +++ b/x-pack/plugins/ml/server/types.ts @@ -31,6 +31,11 @@ export interface SystemRouteDeps { resolveMlCapabilities: ResolveMlCapabilities; } +export interface SavedObjectsRouteDeps { + getSpaces?: () => Promise; + resolveMlCapabilities: ResolveMlCapabilities; +} + export interface PluginsSetup { cloud: CloudSetup; features: FeaturesPluginSetup; From 5e183dd46d9e901827aa8c5246e72bd5e4092067 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Tue, 24 Nov 2020 11:57:23 -0500 Subject: [PATCH 31/89] [Security Solution][Resolver] Allow a configurable entity_id field (#81679) * Trying to flesh out new tree route * Working on the descendants query * Almost working descendants * Possible solution for aggs * Working aggregations extraction * Working on the ancestry array for descendants * Making changes to the unique id for ancestr * Implementing ancestry funcitonality * Deleting the multiple edges * Fleshing out the descendants loop for levels * Writing tests for ancestors and descendants * Fixing type errors and writing more tests * Renaming validation variable and deprecating old tree routes * Renaming tree integration test file * Adding some integration tests * Fixing ancestry to handle multiple nodes in the request and writing more tests * Adding more tests * Renaming new tree to handler file * Renaming new tree directory * Adding more unit tests * Using doc value fields and working on types * Adding comments and more tests * Fixing timestamp test issue * Adding more comments * Fixing timestamp test issue take 2 * Adding id, parent, and name fields to the top level response * Fixing generator start and end time generation * Adding more comments * Revert "Fixing generator start and end time generation" This reverts commit 9e9abf68a6612f25ef9c9c85645f2e1bf72c9359. * Adding test for time Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/endpoint/generate_data.test.ts | 26 +- .../common/endpoint/generate_data.ts | 38 +- .../common/endpoint/schema/resolver.ts | 42 +- .../common/endpoint/types/index.ts | 50 + .../server/endpoint/routes/resolver.ts | 31 +- .../server/endpoint/routes/resolver/tree.ts | 7 +- .../endpoint/routes/resolver/tree/handler.ts | 28 + .../resolver/tree/queries/descendants.ts | 206 +++++ .../routes/resolver/tree/queries/lifecycle.ts | 101 ++ .../routes/resolver/tree/queries/stats.ts | 139 +++ .../routes/resolver/tree/utils/fetch.test.ts | 707 ++++++++++++++ .../routes/resolver/tree/utils/fetch.ts | 334 +++++++ .../routes/resolver/tree/utils/index.ts | 62 ++ .../apis/resolver/common.ts | 361 +++++++- .../apis/resolver/index.ts | 1 + .../apis/resolver/tree.ts | 867 ++++++++++++------ .../apis/resolver/tree_entity_id.ts | 375 ++++++++ 17 files changed, 3082 insertions(+), 293 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/handler.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts create mode 100644 x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree_entity_id.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts index 66119e098238..ec82f4795158 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts @@ -27,7 +27,6 @@ interface Node { } describe('data generator data streams', () => { - // these tests cast the result of the generate methods so that we can specifically compare the `data_stream` fields it('creates a generator with default data streams', () => { const generator = new EndpointDocGenerator('seed'); expect(generator.generateHostMetadata().data_stream).toEqual({ @@ -268,6 +267,31 @@ describe('data generator', () => { } }; + it('sets the start and end times correctly', () => { + const startOfEpoch = new Date(0); + let startTime = new Date(timestampSafeVersion(tree.allEvents[0]) ?? startOfEpoch); + expect(startTime).not.toEqual(startOfEpoch); + let endTime = new Date(timestampSafeVersion(tree.allEvents[0]) ?? startOfEpoch); + expect(startTime).not.toEqual(startOfEpoch); + + for (const event of tree.allEvents) { + const currentEventTime = new Date(timestampSafeVersion(event) ?? startOfEpoch); + expect(currentEventTime).not.toEqual(startOfEpoch); + expect(tree.startTime.getTime()).toBeLessThanOrEqual(currentEventTime.getTime()); + expect(tree.endTime.getTime()).toBeGreaterThanOrEqual(currentEventTime.getTime()); + if (currentEventTime < startTime) { + startTime = currentEventTime; + } + + if (currentEventTime > endTime) { + endTime = currentEventTime; + } + } + expect(startTime).toEqual(tree.startTime); + expect(endTime).toEqual(tree.endTime); + expect(endTime.getTime() - startTime.getTime()).toBeGreaterThanOrEqual(0); + }); + it('creates related events in ascending order', () => { // the order should not change since it should already be in ascending order const relatedEventsAsc = _.cloneDeep(tree.origin.relatedEvents).sort( diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index a4bdc4fc59a7..3c508bed5b2f 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -317,6 +317,8 @@ export interface Tree { * All events from children, ancestry, origin, and the alert in a single array */ allEvents: Event[]; + startTime: Date; + endTime: Date; } export interface TreeOptions { @@ -718,6 +720,35 @@ export class EndpointDocGenerator { }; } + private static getStartEndTimes(events: Event[]): { startTime: Date; endTime: Date } { + let startTime: number; + let endTime: number; + if (events.length > 0) { + startTime = timestampSafeVersion(events[0]) ?? new Date().getTime(); + endTime = startTime; + } else { + startTime = new Date().getTime(); + endTime = startTime; + } + + for (const event of events) { + const eventTimestamp = timestampSafeVersion(event); + if (eventTimestamp !== undefined) { + if (eventTimestamp < startTime) { + startTime = eventTimestamp; + } + + if (eventTimestamp > endTime) { + endTime = eventTimestamp; + } + } + } + return { + startTime: new Date(startTime), + endTime: new Date(endTime), + }; + } + /** * This generates a full resolver tree and keeps the entire tree in memory. This is useful for tests that want * to compare results from elasticsearch with the actual events created by this generator. Because all the events @@ -815,12 +846,17 @@ export class EndpointDocGenerator { const childrenByParent = groupNodesByParent(childrenNodes); const levels = createLevels(childrenByParent, [], childrenByParent.get(origin.id)); + const allEvents = [...ancestry, ...children]; + const { startTime, endTime } = EndpointDocGenerator.getStartEndTimes(allEvents); + return { children: childrenNodes, ancestry: ancestryNodes, - allEvents: [...ancestry, ...children], + allEvents, origin, childrenLevels: levels, + startTime, + endTime, }; } diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index 1dd5668b3177..6777b1dabbd5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -7,9 +7,9 @@ import { schema } from '@kbn/config-schema'; /** - * Used to validate GET requests for a complete resolver tree. + * Used to validate GET requests for a complete resolver tree centered around an entity_id. */ -export const validateTree = { +export const validateTreeEntityID = { params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ children: schema.number({ defaultValue: 200, min: 0, max: 10000 }), @@ -23,6 +23,44 @@ export const validateTree = { }), }; +/** + * Used to validate GET requests for a complete resolver tree. + */ +export const validateTree = { + body: schema.object({ + /** + * If the ancestry field is specified this field will be ignored + * + * If the ancestry field is specified we have a much more performant way of retrieving levels so let's not limit + * the number of levels that come back in that scenario. We could still limit it, but what we'd likely have to do + * is get all the levels back like we normally do with the ancestry array, bucket them together by level, and then + * remove the levels that exceeded the requested number which seems kind of wasteful. + */ + descendantLevels: schema.number({ defaultValue: 20, min: 0, max: 1000 }), + descendants: schema.number({ defaultValue: 1000, min: 0, max: 10000 }), + // if the ancestry array isn't specified allowing 200 might be too high + ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), + timerange: schema.object({ + from: schema.string(), + to: schema.string(), + }), + schema: schema.object({ + // the ancestry field is optional + ancestry: schema.maybe(schema.string({ minLength: 1 })), + id: schema.string({ minLength: 1 }), + name: schema.maybe(schema.string({ minLength: 1 })), + parent: schema.string({ minLength: 1 }), + }), + // only allowing strings and numbers for node IDs because Elasticsearch only allows those types for collapsing: + // https://www.elastic.co/guide/en/elasticsearch/reference/current/collapse-search-results.html + // We use collapsing in our Elasticsearch queries for the tree api + nodes: schema.arrayOf(schema.oneOf([schema.string({ minLength: 1 }), schema.number()]), { + minSize: 1, + }), + indexPatterns: schema.arrayOf(schema.string(), { minSize: 1 }), + }), +}; + /** * Used to validate POST requests for `/resolver/events` api. */ diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index e7d060b463ab..cd5c60e2698c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -78,6 +78,56 @@ export interface EventStats { byCategory: Record; } +/** + * Represents the object structure of a returned document when using doc value fields to filter the fields + * returned in a document from an Elasticsearch query. + * + * Here is an example: + * + * { + * "_index": ".ds-logs-endpoint.events.process-default-000001", + * "_id": "bc7brnUBxO0aE7QcCVHo", + * "_score": null, + * "fields": { <----------- The FieldsObject represents this portion + * "@timestamp": [ + * "2020-11-09T21:13:25.246Z" + * ], + * "process.name": "explorer.exe", + * "process.parent.entity_id": [ + * "0i17c2m22c" + * ], + * "process.Ext.ancestry": [ <------------ Notice that the keys are flattened + * "0i17c2m22c", + * "2z9j8dlx72", + * "oj61pr6g62", + * "x0leonbrc9" + * ], + * "process.entity_id": [ + * "6k8waczi22" + * ] + * }, + * "sort": [ + * 0, + * 1604956405246 + * ] + * } + */ +export interface FieldsObject { + [key: string]: ECSField; +} + +/** + * A node in a resolver graph. + */ +export interface ResolverNode { + data: FieldsObject; + id: string | number; + // the very root node might not have the parent field defined + parent?: string | number; + name?: string; + stats: EventStats; +} + /** * Statistical information for a node in a resolver tree. */ diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts index b5d657fe55a1..42a69d7b1e96 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts @@ -7,16 +7,18 @@ import { IRouter } from 'kibana/server'; import { EndpointAppContext } from '../types'; import { - validateTree, + validateTreeEntityID, validateEvents, validateChildren, validateAncestry, validateAlerts, validateEntities, + validateTree, } from '../../../common/endpoint/schema/resolver'; import { handleChildren } from './resolver/children'; import { handleAncestry } from './resolver/ancestry'; -import { handleTree } from './resolver/tree'; +import { handleTree as handleTreeEntityID } from './resolver/tree'; +import { handleTree } from './resolver/tree/handler'; import { handleAlerts } from './resolver/alerts'; import { handleEntities } from './resolver/entity'; import { handleEvents } from './resolver/events'; @@ -24,6 +26,15 @@ import { handleEvents } from './resolver/events'; export function registerResolverRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { const log = endpointAppContext.logFactory.get('resolver'); + router.post( + { + path: '/api/endpoint/resolver/tree', + validate: validateTree, + options: { authRequired: true }, + }, + handleTree(log) + ); + router.post( { path: '/api/endpoint/resolver/events', @@ -33,6 +44,9 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleEvents(log) ); + /** + * @deprecated will be removed because it is not used + */ router.post( { path: '/api/endpoint/resolver/{id}/alerts', @@ -42,6 +56,9 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleAlerts(log, endpointAppContext) ); + /** + * @deprecated use the /resolver/tree api instead + */ router.get( { path: '/api/endpoint/resolver/{id}/children', @@ -51,6 +68,9 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleChildren(log, endpointAppContext) ); + /** + * @deprecated use the /resolver/tree api instead + */ router.get( { path: '/api/endpoint/resolver/{id}/ancestry', @@ -60,13 +80,16 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleAncestry(log, endpointAppContext) ); + /** + * @deprecated use the /resolver/tree api instead + */ router.get( { path: '/api/endpoint/resolver/{id}', - validate: validateTree, + validate: validateTreeEntityID, options: { authRequired: true }, }, - handleTree(log, endpointAppContext) + handleTreeEntityID(log, endpointAppContext) ); /** diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts index 02cddc3ddcf6..08cb9b56bf64 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts @@ -7,14 +7,17 @@ import { RequestHandler, Logger } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; import { eventsIndexPattern, alertsIndexPattern } from '../../../../common/endpoint/constants'; -import { validateTree } from '../../../../common/endpoint/schema/resolver'; +import { validateTreeEntityID } from '../../../../common/endpoint/schema/resolver'; import { Fetcher } from './utils/fetch'; import { EndpointAppContext } from '../../types'; export function handleTree( log: Logger, endpointAppContext: EndpointAppContext -): RequestHandler, TypeOf> { +): RequestHandler< + TypeOf, + TypeOf +> { return async (context, req, res) => { try { const client = context.core.elasticsearch.legacy.client; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/handler.ts new file mode 100644 index 000000000000..8c62cf876298 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/handler.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandler, Logger } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { validateTree } from '../../../../../common/endpoint/schema/resolver'; +import { Fetcher } from './utils/fetch'; + +export function handleTree( + log: Logger +): RequestHandler> { + return async (context, req, res) => { + try { + const client = context.core.elasticsearch.client; + const fetcher = new Fetcher(client); + const body = await fetcher.tree(req.body); + return res.ok({ + body, + }); + } catch (err) { + log.warn(err); + return res.internalError({ body: 'Error retrieving tree.' }); + } + }; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts new file mode 100644 index 000000000000..405429cc2419 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts @@ -0,0 +1,206 @@ +/* + * 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 { SearchResponse } from 'elasticsearch'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { IScopedClusterClient } from 'src/core/server'; +import { FieldsObject } from '../../../../../../common/endpoint/types'; +import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; +import { NodeID, Schema, Timerange, docValueFields } from '../utils/index'; + +interface DescendantsParams { + schema: Schema; + indexPatterns: string | string[]; + timerange: Timerange; +} + +/** + * Builds a query for retrieving descendants of a node. + */ +export class DescendantsQuery { + private readonly schema: Schema; + private readonly indexPatterns: string | string[]; + private readonly timerange: Timerange; + private readonly docValueFields: JsonValue[]; + constructor({ schema, indexPatterns, timerange }: DescendantsParams) { + this.docValueFields = docValueFields(schema); + this.schema = schema; + this.indexPatterns = indexPatterns; + this.timerange = timerange; + } + + private query(nodes: NodeID[], size: number): JsonObject { + return { + _source: false, + docvalue_fields: this.docValueFields, + size, + collapse: { + field: this.schema.id, + }, + sort: [{ '@timestamp': 'asc' }], + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: this.timerange.from, + lte: this.timerange.to, + format: 'strict_date_optional_time', + }, + }, + }, + { + terms: { [this.schema.parent]: nodes }, + }, + { + exists: { + field: this.schema.id, + }, + }, + { + exists: { + field: this.schema.parent, + }, + }, + { + term: { 'event.category': 'process' }, + }, + { + term: { 'event.kind': 'event' }, + }, + ], + }, + }, + }; + } + + private queryWithAncestryArray(nodes: NodeID[], ancestryField: string, size: number): JsonObject { + return { + _source: false, + docvalue_fields: this.docValueFields, + size, + collapse: { + field: this.schema.id, + }, + sort: [ + { + _script: { + type: 'number', + script: { + /** + * This script is used to sort the returned documents in a breadth first order so that we return all of + * a single level of nodes before returning the next level of nodes. This is needed because using the + * ancestry array could result in the search going deep before going wide depending on when the nodes + * spawned their children. If a node spawns a child before it's sibling is spawned then the child would + * be found before the sibling because by default the sort was on timestamp ascending. + */ + source: ` + Map ancestryToIndex = [:]; + List sourceAncestryArray = params._source.${ancestryField}; + int length = sourceAncestryArray.length; + for (int i = 0; i < length; i++) { + ancestryToIndex[sourceAncestryArray[i]] = i; + } + for (String id : params.ids) { + def index = ancestryToIndex[id]; + if (index != null) { + return index; + } + } + return -1; + `, + params: { + ids: nodes, + }, + }, + }, + }, + { '@timestamp': 'asc' }, + ], + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: this.timerange.from, + lte: this.timerange.to, + format: 'strict_date_optional_time', + }, + }, + }, + { + terms: { + [ancestryField]: nodes, + }, + }, + { + exists: { + field: this.schema.id, + }, + }, + { + exists: { + field: this.schema.parent, + }, + }, + { + exists: { + field: ancestryField, + }, + }, + { + term: { 'event.category': 'process' }, + }, + { + term: { 'event.kind': 'event' }, + }, + ], + }, + }, + }; + } + + /** + * Searches for descendant nodes matching the specified IDs. + * + * @param client for making requests to Elasticsearch + * @param nodes the unique IDs to search for in Elasticsearch + * @param limit the upper limit of documents to returned + */ + async search( + client: IScopedClusterClient, + nodes: NodeID[], + limit: number + ): Promise { + if (nodes.length <= 0) { + return []; + } + + let response: ApiResponse>; + if (this.schema.ancestry) { + response = await client.asCurrentUser.search({ + body: this.queryWithAncestryArray(nodes, this.schema.ancestry, limit), + index: this.indexPatterns, + }); + } else { + response = await client.asCurrentUser.search({ + body: this.query(nodes, limit), + index: this.indexPatterns, + }); + } + + /** + * The returned values will look like: + * [ + * { 'schema_id_value': , 'schema_parent_value': } + * ] + * + * So the schema fields are flattened ('process.parent.entity_id') + */ + return response.body.hits.hits.map((hit) => hit.fields); + } +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts new file mode 100644 index 000000000000..606a4538ba88 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts @@ -0,0 +1,101 @@ +/* + * 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 { SearchResponse } from 'elasticsearch'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { IScopedClusterClient } from 'src/core/server'; +import { FieldsObject } from '../../../../../../common/endpoint/types'; +import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; +import { NodeID, Schema, Timerange, docValueFields } from '../utils/index'; + +interface LifecycleParams { + schema: Schema; + indexPatterns: string | string[]; + timerange: Timerange; +} + +/** + * Builds a query for retrieving descendants of a node. + */ +export class LifecycleQuery { + private readonly schema: Schema; + private readonly indexPatterns: string | string[]; + private readonly timerange: Timerange; + private readonly docValueFields: JsonValue[]; + constructor({ schema, indexPatterns, timerange }: LifecycleParams) { + this.docValueFields = docValueFields(schema); + this.schema = schema; + this.indexPatterns = indexPatterns; + this.timerange = timerange; + } + + private query(nodes: NodeID[]): JsonObject { + return { + _source: false, + docvalue_fields: this.docValueFields, + size: nodes.length, + collapse: { + field: this.schema.id, + }, + sort: [{ '@timestamp': 'asc' }], + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: this.timerange.from, + lte: this.timerange.to, + format: 'strict_date_optional_time', + }, + }, + }, + { + terms: { [this.schema.id]: nodes }, + }, + { + exists: { + field: this.schema.id, + }, + }, + { + term: { 'event.category': 'process' }, + }, + { + term: { 'event.kind': 'event' }, + }, + ], + }, + }, + }; + } + + /** + * Searches for lifecycle events matching the specified node IDs. + * + * @param client for making requests to Elasticsearch + * @param nodes the unique IDs to search for in Elasticsearch + */ + async search(client: IScopedClusterClient, nodes: NodeID[]): Promise { + if (nodes.length <= 0) { + return []; + } + + const response: ApiResponse> = await client.asCurrentUser.search({ + body: this.query(nodes), + index: this.indexPatterns, + }); + + /** + * The returned values will look like: + * [ + * { 'schema_id_value': , 'schema_parent_value': } + * ] + * + * So the schema fields are flattened ('process.parent.entity_id') + */ + return response.body.hits.hits.map((hit) => hit.fields); + } +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts new file mode 100644 index 000000000000..33dcdce8987f --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SearchResponse } from 'elasticsearch'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { IScopedClusterClient } from 'src/core/server'; +import { JsonObject } from '../../../../../../../../../src/plugins/kibana_utils/common'; +import { EventStats } from '../../../../../../common/endpoint/types'; +import { NodeID, Schema, Timerange } from '../utils/index'; + +interface AggBucket { + key: string; + doc_count: number; +} + +interface CategoriesAgg extends AggBucket { + /** + * The reason categories is optional here is because if no data was returned in the query the categories aggregation + * will not be defined on the response (because it's a sub aggregation). + */ + categories?: { + buckets?: AggBucket[]; + }; +} + +interface StatsParams { + schema: Schema; + indexPatterns: string | string[]; + timerange: Timerange; +} + +/** + * Builds a query for retrieving descendants of a node. + */ +export class StatsQuery { + private readonly schema: Schema; + private readonly indexPatterns: string | string[]; + private readonly timerange: Timerange; + constructor({ schema, indexPatterns, timerange }: StatsParams) { + this.schema = schema; + this.indexPatterns = indexPatterns; + this.timerange = timerange; + } + + private query(nodes: NodeID[]): JsonObject { + return { + size: 0, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: this.timerange.from, + lte: this.timerange.to, + format: 'strict_date_optional_time', + }, + }, + }, + { + terms: { [this.schema.id]: nodes }, + }, + { + term: { 'event.kind': 'event' }, + }, + { + bool: { + must_not: { + term: { + 'event.category': 'process', + }, + }, + }, + }, + ], + }, + }, + aggs: { + ids: { + terms: { field: this.schema.id, size: nodes.length }, + aggs: { + categories: { + terms: { field: 'event.category', size: 1000 }, + }, + }, + }, + }, + }; + } + + private static getEventStats(catAgg: CategoriesAgg): EventStats { + const total = catAgg.doc_count; + if (!catAgg.categories?.buckets) { + return { + total, + byCategory: {}, + }; + } + + const byCategory: Record = catAgg.categories.buckets.reduce( + (cummulative: Record, bucket: AggBucket) => ({ + ...cummulative, + [bucket.key]: bucket.doc_count, + }), + {} + ); + return { + total, + byCategory, + }; + } + + /** + * Returns the related event statistics for a set of nodes. + * @param client used to make requests to Elasticsearch + * @param nodes an array of unique IDs representing nodes in a resolver graph + */ + async search(client: IScopedClusterClient, nodes: NodeID[]): Promise> { + if (nodes.length <= 0) { + return {}; + } + + // leaving unknown here because we don't actually need the hits part of the body + const response: ApiResponse> = await client.asCurrentUser.search({ + body: this.query(nodes), + index: this.indexPatterns, + }); + + return response.body.aggregations?.ids?.buckets.reduce( + (cummulative: Record, bucket: CategoriesAgg) => ({ + ...cummulative, + [bucket.key]: StatsQuery.getEventStats(bucket), + }), + {} + ); + } +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts new file mode 100644 index 000000000000..8105f1125d01 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts @@ -0,0 +1,707 @@ +/* + * 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 { + Fetcher, + getAncestryAsArray, + getIDField, + getLeafNodes, + getNameField, + getParentField, + TreeOptions, +} from './fetch'; +import { LifecycleQuery } from '../queries/lifecycle'; +import { DescendantsQuery } from '../queries/descendants'; +import { StatsQuery } from '../queries/stats'; +import { IScopedClusterClient } from 'src/core/server'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { FieldsObject, ResolverNode } from '../../../../../../common/endpoint/types'; +import { Schema } from './index'; + +jest.mock('../queries/descendants'); +jest.mock('../queries/lifecycle'); +jest.mock('../queries/stats'); + +function formatResponse(results: FieldsObject[], schema: Schema): ResolverNode[] { + return results.map((node) => { + return { + id: getIDField(node, schema) ?? '', + parent: getParentField(node, schema), + name: getNameField(node, schema), + data: node, + stats: { + total: 0, + byCategory: {}, + }, + }; + }); +} + +describe('fetcher test', () => { + const schemaIDParent = { + id: 'id', + parent: 'parent', + }; + + const schemaIDParentAncestry = { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }; + + const schemaIDParentName = { + id: 'id', + parent: 'parent', + name: 'name', + }; + + let client: jest.Mocked; + beforeAll(() => { + StatsQuery.prototype.search = jest.fn().mockImplementation(async () => { + return {}; + }); + }); + beforeEach(() => { + client = elasticsearchServiceMock.createScopedClusterClient(); + }); + + describe('descendants', () => { + it('correctly exists loop when the search returns no results', async () => { + DescendantsQuery.prototype.search = jest.fn().mockImplementationOnce(async () => { + return []; + }); + const options: TreeOptions = { + descendantLevels: 1, + descendants: 5, + ancestors: 0, + timerange: { + from: '', + to: '', + }, + schema: { + id: '', + parent: '', + }, + indexPatterns: [''], + nodes: ['a'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual([]); + }); + + it('exists the loop when the options specify no descendants', async () => { + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 0, + timerange: { + from: '', + to: '', + }, + schema: { + id: '', + parent: '', + }, + indexPatterns: [''], + nodes: ['a'], + }; + + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual([]); + }); + + it('returns the correct results without the ancestry defined', async () => { + /** + . + └── 0 + ├── 1 + │ └── 2 + └── 3 + ├── 4 + └── 5 + */ + const level1 = [ + { + id: '1', + parent: '0', + }, + { + id: '3', + parent: '0', + }, + ]; + const level2 = [ + { + id: '2', + parent: '1', + }, + + { + id: '4', + parent: '3', + }, + { + id: '5', + parent: '3', + }, + ]; + DescendantsQuery.prototype.search = jest + .fn() + .mockImplementationOnce(async () => { + return level1; + }) + .mockImplementationOnce(async () => { + return level2; + }); + const options: TreeOptions = { + descendantLevels: 2, + descendants: 5, + ancestors: 0, + timerange: { + from: '', + to: '', + }, + schema: schemaIDParent, + indexPatterns: [''], + nodes: ['0'], + }; + + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual( + formatResponse([...level1, ...level2], schemaIDParent) + ); + }); + }); + + describe('ancestors', () => { + it('correctly exits loop when the search returns no results', async () => { + LifecycleQuery.prototype.search = jest.fn().mockImplementationOnce(async () => { + return []; + }); + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 5, + timerange: { + from: '', + to: '', + }, + schema: { + id: '', + parent: '', + }, + indexPatterns: [''], + nodes: ['a'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual([]); + }); + + it('correctly exits loop when the options specify no ancestors', async () => { + LifecycleQuery.prototype.search = jest.fn().mockImplementationOnce(async () => { + throw new Error('should not have called this'); + }); + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 0, + timerange: { + from: '', + to: '', + }, + schema: { + id: '', + parent: '', + }, + indexPatterns: [''], + nodes: ['a'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual([]); + }); + + it('correctly returns the ancestors when the number of levels has been reached', async () => { + LifecycleQuery.prototype.search = jest + .fn() + .mockImplementationOnce(async () => { + return [ + { + id: '3', + parent: '2', + }, + ]; + }) + .mockImplementationOnce(async () => { + return [ + { + id: '2', + parent: '1', + }, + ]; + }); + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 2, + timerange: { + from: '', + to: '', + }, + schema: schemaIDParent, + indexPatterns: [''], + nodes: ['3'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual( + formatResponse( + [ + { id: '3', parent: '2' }, + { id: '2', parent: '1' }, + ], + schemaIDParent + ) + ); + }); + + it('correctly adds name field to response', async () => { + LifecycleQuery.prototype.search = jest + .fn() + .mockImplementationOnce(async () => { + return [ + { + id: '3', + parent: '2', + }, + ]; + }) + .mockImplementationOnce(async () => { + return [ + { + id: '2', + parent: '1', + }, + ]; + }); + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 2, + timerange: { + from: '', + to: '', + }, + schema: schemaIDParentName, + indexPatterns: [''], + nodes: ['3'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual( + formatResponse( + [ + { id: '3', parent: '2' }, + { id: '2', parent: '1' }, + ], + schemaIDParentName + ) + ); + }); + + it('correctly returns the ancestors with ancestry arrays', async () => { + const node3 = { + ancestry: ['2', '1'], + id: '3', + parent: '2', + }; + + const node1 = { + ancestry: ['0'], + id: '1', + parent: '0', + }; + + const node2 = { + ancestry: ['1', '0'], + id: '2', + parent: '1', + }; + LifecycleQuery.prototype.search = jest + .fn() + .mockImplementationOnce(async () => { + return [node3]; + }) + .mockImplementationOnce(async () => { + return [node1, node2]; + }); + const options: TreeOptions = { + descendantLevels: 0, + descendants: 0, + ancestors: 3, + timerange: { + from: '', + to: '', + }, + schema: schemaIDParentAncestry, + indexPatterns: [''], + nodes: ['3'], + }; + const fetcher = new Fetcher(client); + expect(await fetcher.tree(options)).toEqual( + formatResponse([node3, node1, node2], schemaIDParentAncestry) + ); + }); + }); + + describe('retrieving leaf nodes', () => { + it('correctly identifies the leaf nodes in a response without the ancestry field', () => { + /** + . + └── 0 + ├── 1 + ├── 2 + └── 3 + */ + const results = [ + { + id: '1', + parent: '0', + }, + { + id: '2', + parent: '0', + }, + { + id: '3', + parent: '0', + }, + ]; + const leaves = getLeafNodes(results, ['0'], { id: 'id', parent: 'parent' }); + expect(leaves).toStrictEqual(['1', '2', '3']); + }); + + it('correctly ignores nodes without the proper fields', () => { + /** + . + └── 0 + ├── 1 + ├── 2 + */ + const results = [ + { + id: '1', + parent: '0', + }, + { + id: '2', + parent: '0', + }, + { + idNotReal: '3', + parentNotReal: '0', + }, + ]; + const leaves = getLeafNodes(results, ['0'], { id: 'id', parent: 'parent' }); + expect(leaves).toStrictEqual(['1', '2']); + }); + + it('returns an empty response when the proper fields are not defined', () => { + const results = [ + { + id: '1', + parentNotReal: '0', + }, + { + id: '2', + parentNotReal: '0', + }, + { + idNotReal: '3', + parent: '0', + }, + ]; + const leaves = getLeafNodes(results, ['0'], { id: 'id', parent: 'parent' }); + expect(leaves).toStrictEqual([]); + }); + + describe('with the ancestry field defined', () => { + it('correctly identifies the leaf nodes in a response with the ancestry field', () => { + /** + . + ├── 1 + │ └── 2 + └── 3 + */ + const results = [ + { + id: '1', + parent: '0', + ancestry: ['0', 'a'], + }, + { + id: '2', + parent: '1', + ancestry: ['1', '0'], + }, + { + id: '3', + parent: '0', + ancestry: ['0', 'a'], + }, + ]; + const leaves = getLeafNodes(results, ['0'], { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }); + expect(leaves).toStrictEqual(['2']); + }); + + it('falls back to using parent field if it cannot find the ancestry field', () => { + /** + . + ├── 1 + │ └── 2 + └── 3 + */ + const results = [ + { + id: '1', + parent: '0', + ancestryNotValid: ['0', 'a'], + }, + { + id: '2', + parent: '1', + }, + { + id: '3', + parent: '0', + }, + ]; + const leaves = getLeafNodes(results, ['0'], { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }); + expect(leaves).toStrictEqual(['1', '3']); + }); + + it('correctly identifies the leaf nodes with a tree with multiple leaves', () => { + /** + . + └── 0 + ├── 1 + │ └── 2 + └── 3 + ├── 4 + └── 5 + */ + const results = [ + { + id: '1', + parent: '0', + ancestry: ['0', 'a'], + }, + { + id: '2', + parent: '1', + ancestry: ['1', '0'], + }, + { + id: '3', + parent: '0', + ancestry: ['0', 'a'], + }, + { + id: '4', + parent: '3', + ancestry: ['3', '0'], + }, + { + id: '5', + parent: '3', + ancestry: ['3', '0'], + }, + ]; + const leaves = getLeafNodes(results, ['0'], { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }); + expect(leaves).toStrictEqual(['2', '4', '5']); + }); + + it('correctly identifies the leaf nodes with multiple queried nodes', () => { + /** + . + ├── 0 + │ ├── 1 + │ │ └── 2 + │ └── 3 + │ ├── 4 + │ └── 5 + └── a + └── b + ├── c + └── d + */ + const results = [ + { + id: '1', + parent: '0', + ancestry: ['0'], + }, + { + id: '2', + parent: '1', + ancestry: ['1', '0'], + }, + { + id: '3', + parent: '0', + ancestry: ['0'], + }, + { + id: '4', + parent: '3', + ancestry: ['3', '0'], + }, + { + id: '5', + parent: '3', + ancestry: ['3', '0'], + }, + { + id: 'b', + parent: 'a', + ancestry: ['a'], + }, + { + id: 'c', + parent: 'b', + ancestry: ['b', 'a'], + }, + { + id: 'd', + parent: 'b', + ancestry: ['b', 'a'], + }, + ]; + const leaves = getLeafNodes(results, ['0', 'a'], { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }); + expect(leaves).toStrictEqual(['2', '4', '5', 'c', 'd']); + }); + + it('correctly identifies the leaf nodes with an unbalanced tree', () => { + /** + . + ├── 0 + │ ├── 1 + │ │ └── 2 + │ └── 3 + │ ├── 4 + │ └── 5 + └── a + └── b + */ + const results = [ + { + id: '1', + parent: '0', + ancestry: ['0'], + }, + { + id: '2', + parent: '1', + ancestry: ['1', '0'], + }, + { + id: '3', + parent: '0', + ancestry: ['0'], + }, + { + id: '4', + parent: '3', + ancestry: ['3', '0'], + }, + { + id: '5', + parent: '3', + ancestry: ['3', '0'], + }, + { + id: 'b', + parent: 'a', + ancestry: ['a'], + }, + ]; + const leaves = getLeafNodes(results, ['0', 'a'], { + id: 'id', + parent: 'parent', + ancestry: 'ancestry', + }); + // the reason b is not identified here is because the ancestry array + // size is 2, which means that if b had a descendant, then it would have been found + // using our query which found 2, 4, 5. So either we hit the size limit or there are no + // children of b + expect(leaves).toStrictEqual(['2', '4', '5']); + }); + }); + }); + + describe('getIDField', () => { + it('returns undefined if the field does not exist', () => { + expect(getIDField({}, { id: 'a', parent: 'b' })).toBeUndefined(); + }); + + it('returns the first value if the field is an array', () => { + expect(getIDField({ 'a.b': ['1', '2'] }, { id: 'a.b', parent: 'b' })).toStrictEqual('1'); + }); + }); + + describe('getParentField', () => { + it('returns undefined if the field does not exist', () => { + expect(getParentField({}, { id: 'a', parent: 'b' })).toBeUndefined(); + }); + + it('returns the first value if the field is an array', () => { + expect(getParentField({ 'a.b': ['1', '2'] }, { id: 'z', parent: 'a.b' })).toStrictEqual('1'); + }); + }); + + describe('getAncestryAsArray', () => { + it('returns an empty array if the field does not exist', () => { + expect(getAncestryAsArray({}, { id: 'a', parent: 'b', ancestry: 'z' })).toStrictEqual([]); + }); + + it('returns the full array if the field exists', () => { + expect( + getAncestryAsArray({ 'a.b': ['1', '2'] }, { id: 'z', parent: 'f', ancestry: 'a.b' }) + ).toStrictEqual(['1', '2']); + }); + + it('returns a built array using the parent field if ancestry field is empty', () => { + expect( + getAncestryAsArray( + { 'aParent.bParent': ['1', '2'], ancestry: [] }, + { id: 'z', parent: 'aParent.bParent', ancestry: 'ancestry' } + ) + ).toStrictEqual(['1']); + }); + + it('returns a built array using the parent field if ancestry field does not exist', () => { + expect( + getAncestryAsArray( + { 'aParent.bParent': '1' }, + { id: 'z', parent: 'aParent.bParent', ancestry: 'ancestry' } + ) + ).toStrictEqual(['1']); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts new file mode 100644 index 000000000000..eaecad6c4797 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts @@ -0,0 +1,334 @@ +/* + * 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 { IScopedClusterClient } from 'kibana/server'; +import { + firstNonNullValue, + values, +} from '../../../../../../common/endpoint/models/ecs_safety_helpers'; +import { ECSField, ResolverNode, FieldsObject } from '../../../../../../common/endpoint/types'; +import { DescendantsQuery } from '../queries/descendants'; +import { Schema, NodeID } from './index'; +import { LifecycleQuery } from '../queries/lifecycle'; +import { StatsQuery } from '../queries/stats'; + +/** + * The query parameters passed in from the request. These define the limits for the ES requests for retrieving the + * resolver tree. + */ +export interface TreeOptions { + descendantLevels: number; + descendants: number; + ancestors: number; + timerange: { + from: string; + to: string; + }; + schema: Schema; + nodes: NodeID[]; + indexPatterns: string[]; +} + +/** + * Handles retrieving nodes of a resolver tree. + */ +export class Fetcher { + constructor(private readonly client: IScopedClusterClient) {} + + /** + * This method retrieves the ancestors and descendants of a resolver tree. + * + * @param options the options for retrieving the structure of the tree. + */ + public async tree(options: TreeOptions): Promise { + const treeParts = await Promise.all([ + this.retrieveAncestors(options), + this.retrieveDescendants(options), + ]); + + const tree = treeParts.reduce((results, partArray) => { + results.push(...partArray); + return results; + }, []); + + return this.formatResponse(tree, options); + } + + private async formatResponse( + treeNodes: FieldsObject[], + options: TreeOptions + ): Promise { + const statsIDs: NodeID[] = []; + for (const node of treeNodes) { + const id = getIDField(node, options.schema); + if (id) { + statsIDs.push(id); + } + } + + const query = new StatsQuery({ + indexPatterns: options.indexPatterns, + schema: options.schema, + timerange: options.timerange, + }); + + const eventStats = await query.search(this.client, statsIDs); + const statsNodes: ResolverNode[] = []; + for (const node of treeNodes) { + const id = getIDField(node, options.schema); + const parent = getParentField(node, options.schema); + const name = getNameField(node, options.schema); + + // at this point id should never be undefined, it should be enforced by the Elasticsearch query + // but let's check anyway + if (id !== undefined) { + statsNodes.push({ + id, + parent, + name, + data: node, + stats: eventStats[id] ?? { total: 0, byCategory: {} }, + }); + } + } + return statsNodes; + } + + private static getNextAncestorsToFind( + results: FieldsObject[], + schema: Schema, + levelsLeft: number + ): NodeID[] { + const nodesByID = results.reduce((accMap: Map, result: FieldsObject) => { + const id = getIDField(result, schema); + if (id) { + accMap.set(id, result); + } + return accMap; + }, new Map()); + + const nodes: NodeID[] = []; + // Find all the nodes that don't have their parent in the result set, we will use these + // nodes to find the additional ancestry + for (const result of results) { + const parent = getParentField(result, schema); + if (parent) { + const parentNode = nodesByID.get(parent); + if (!parentNode) { + // it's ok if the nodes array is larger than the levelsLeft because the query + // will have the size set to the levelsLeft which will restrict the number of results + nodes.push(...getAncestryAsArray(result, schema).slice(0, levelsLeft)); + } + } + } + return nodes; + } + + private async retrieveAncestors(options: TreeOptions): Promise { + const ancestors: FieldsObject[] = []; + const query = new LifecycleQuery({ + schema: options.schema, + indexPatterns: options.indexPatterns, + timerange: options.timerange, + }); + + let nodes = options.nodes; + let numLevelsLeft = options.ancestors; + + while (numLevelsLeft > 0) { + const results: FieldsObject[] = await query.search(this.client, nodes); + if (results.length <= 0) { + return ancestors; + } + + /** + * This array (this.ancestry.ancestors) is the accumulated ancestors of the node of interest. This array is different + * from the ancestry array of a specific document. The order of this array is going to be weird, it will look like this + * [most distant ancestor...closer ancestor, next recursive call most distant ancestor...closer ancestor] + * + * Here is an example of why this happens + * Consider the following tree: + * A -> B -> C -> D -> E -> Origin + * Where A was spawn before B, which was before C, etc + * + * Let's assume the ancestry array limit is 2 so Origin's array would be: [E, D] + * E's ancestry array would be: [D, C] etc + * + * If a request comes in to retrieve all the ancestors in this tree, the accumulate results will be: + * [D, E, B, C, A] + * + * The first iteration would retrieve D and E in that order because they are sorted in ascending order by timestamp. + * The next iteration would get the ancestors of D (since that's the most distant ancestor from Origin) which are + * [B, C] + * The next iteration would get the ancestors of B which is A + * Hence: [D, E, B, C, A] + */ + ancestors.push(...results); + numLevelsLeft -= results.length; + nodes = Fetcher.getNextAncestorsToFind(results, options.schema, numLevelsLeft); + } + return ancestors; + } + + private async retrieveDescendants(options: TreeOptions): Promise { + const descendants: FieldsObject[] = []; + const query = new DescendantsQuery({ + schema: options.schema, + indexPatterns: options.indexPatterns, + timerange: options.timerange, + }); + + let nodes: NodeID[] = options.nodes; + let numNodesLeftToRequest: number = options.descendants; + let levelsLeftToRequest: number = options.descendantLevels; + // if the ancestry was specified then ignore the levels + while ( + numNodesLeftToRequest > 0 && + (options.schema.ancestry !== undefined || levelsLeftToRequest > 0) + ) { + const results: FieldsObject[] = await query.search(this.client, nodes, numNodesLeftToRequest); + if (results.length <= 0) { + return descendants; + } + + nodes = getLeafNodes(results, nodes, options.schema); + + numNodesLeftToRequest -= results.length; + levelsLeftToRequest -= 1; + descendants.push(...results); + } + + return descendants; + } +} + +/** + * This functions finds the leaf nodes for a given response from an Elasticsearch query. + * + * Exporting so it can be tested. + * + * @param results the doc values portion of the documents returned from an Elasticsearch query + * @param nodes an array of unique IDs that were used to find the returned documents + * @param schema the field definitions for how nodes are represented in the resolver graph + */ +export function getLeafNodes( + results: FieldsObject[], + nodes: Array, + schema: Schema +): NodeID[] { + let largestAncestryArray = 0; + const nodesToQueryNext: Map> = new Map(); + const queriedNodes = new Set(nodes); + const isDistantGrandchild = (event: FieldsObject) => { + const ancestry = getAncestryAsArray(event, schema); + return ancestry.length > 0 && queriedNodes.has(ancestry[ancestry.length - 1]); + }; + + for (const result of results) { + const ancestry = getAncestryAsArray(result, schema); + // This is to handle the following unlikely but possible scenario: + // if an alert was generated by the kernel process (parent process of all other processes) then + // the direct children of that process would only have an ancestry array of [parent_kernel], a single value in the array. + // The children of those children would have two values in their array [direct parent, parent_kernel] + // we need to determine which nodes are the most distant grandchildren of the queriedNodes because those should + // be used for the next query if more nodes should be retrieved. To generally determine the most distant grandchildren + // we can use the last entry in the ancestry array because of its ordering. The problem with that is in the scenario above + // the direct children of parent_kernel will also meet that criteria even though they are not actually the most + // distant grandchildren. To get around that issue we'll bucket all the nodes by the size of their ancestry array + // and then only return the nodes in the largest bucket because those should be the most distant grandchildren + // from the queried nodes that were passed in. + if (ancestry.length > largestAncestryArray) { + largestAncestryArray = ancestry.length; + } + + // a grandchild must have an array of > 0 and have it's last parent be in the set of previously queried nodes + // this is one of the furthest descendants from the queried nodes + if (isDistantGrandchild(result)) { + let levelOfNodes = nodesToQueryNext.get(ancestry.length); + if (!levelOfNodes) { + levelOfNodes = new Set(); + nodesToQueryNext.set(ancestry.length, levelOfNodes); + } + const nodeID = getIDField(result, schema); + if (nodeID) { + levelOfNodes.add(nodeID); + } + } + } + const nextNodes = nodesToQueryNext.get(largestAncestryArray); + + return nextNodes !== undefined ? Array.from(nextNodes) : []; +} + +/** + * Retrieves the unique ID field from a document. + * + * Exposed for testing. + * @param obj the doc value fields retrieved from a document returned by Elasticsearch + * @param schema the schema used for identifying connections between documents + */ +export function getIDField(obj: FieldsObject, schema: Schema): NodeID | undefined { + const id: ECSField = obj[schema.id]; + return firstNonNullValue(id); +} + +/** + * Retrieves the name field from a document. + * + * Exposed for testing. + * @param obj the doc value fields retrieved from a document returned by Elasticsearch + * @param schema the schema used for identifying connections between documents + */ +export function getNameField(obj: FieldsObject, schema: Schema): string | undefined { + if (!schema.name) { + return undefined; + } + + const name: ECSField = obj[schema.name]; + return String(firstNonNullValue(name)); +} + +/** + * Retrieves the unique parent ID field from a document. + * + * Exposed for testing. + * @param obj the doc value fields retrieved from a document returned by Elasticsearch + * @param schema the schema used for identifying connections between documents + */ +export function getParentField(obj: FieldsObject, schema: Schema): NodeID | undefined { + const parent: ECSField = obj[schema.parent]; + return firstNonNullValue(parent); +} + +function getAncestryField(obj: FieldsObject, schema: Schema): NodeID[] | undefined { + if (!schema.ancestry) { + return undefined; + } + + const ancestry: ECSField = obj[schema.ancestry]; + if (!ancestry) { + return undefined; + } + + return values(ancestry); +} + +/** + * Retrieves the ancestry array field if it exists. If it doesn't exist or if it is empty it reverts to + * creating an array using the parent field. If the parent field doesn't exist, it returns + * an empty array. + * + * Exposed for testing. + * @param obj the doc value fields retrieved from a document returned by Elasticsearch + * @param schema the schema used for identifying connections between documents + */ +export function getAncestryAsArray(obj: FieldsObject, schema: Schema): NodeID[] { + const ancestry = getAncestryField(obj, schema); + if (!ancestry || ancestry.length <= 0) { + const parentField = getParentField(obj, schema); + return parentField !== undefined ? [parentField] : []; + } + return ancestry; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts new file mode 100644 index 000000000000..21a49e268310 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Represents a time range filter + */ +export interface Timerange { + from: string; + to: string; +} + +/** + * An array of unique IDs to identify nodes within the resolver tree. + */ +export type NodeID = string | number; + +/** + * The fields to use to identify nodes within a resolver tree. + */ +export interface Schema { + /** + * the ancestry field should be set to a field that contains an order array representing + * the ancestors of a node. + */ + ancestry?: string; + /** + * id represents the field to use as the unique ID for a node. + */ + id: string; + /** + * field to use for the name of the node + */ + name?: string; + /** + * parent represents the field that is the edge between two nodes. + */ + parent: string; +} + +/** + * Returns the doc value fields filter to use in queries to limit the number of fields returned in the + * query response. + * + * See for more info: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-fields.html#docvalue-fields + * + * @param schema is the node schema information describing how relationships are formed between nodes + * in the resolver graph. + */ +export function docValueFields(schema: Schema): Array<{ field: string }> { + const filter = [{ field: '@timestamp' }, { field: schema.id }, { field: schema.parent }]; + if (schema.ancestry) { + filter.push({ field: schema.ancestry }); + } + + if (schema.name) { + filter.push({ field: schema.name }); + } + return filter; +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts index 2c59863099ae..b4e98d7d4b95 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts @@ -5,16 +5,24 @@ */ import _ from 'lodash'; import expect from '@kbn/expect'; +import { firstNonNullValue } from '../../../../plugins/security_solution/common/endpoint/models/ecs_safety_helpers'; +import { + NodeID, + Schema, +} from '../../../../plugins/security_solution/server/endpoint/routes/resolver/tree/utils'; import { SafeResolverChildNode, SafeResolverLifecycleNode, SafeResolverEvent, ResolverNodeStats, + ResolverNode, } from '../../../../plugins/security_solution/common/endpoint/types'; import { parentEntityIDSafeVersion, entityIDSafeVersion, eventIDSafeVersion, + timestampSafeVersion, + timestampAsDateSafeVersion, } from '../../../../plugins/security_solution/common/endpoint/models/event'; import { Event, @@ -24,6 +32,344 @@ import { categoryMapping, } from '../../../../plugins/security_solution/common/endpoint/generate_data'; +const createLevels = ({ + descendantsByParent, + levels, + currentNodes, + schema, +}: { + descendantsByParent: Map>; + levels: Array>; + currentNodes: Map | undefined; + schema: Schema; +}): Array> => { + if (!currentNodes || currentNodes.size === 0) { + return levels; + } + levels.push(currentNodes); + const nextLevel: Map = new Map(); + for (const node of currentNodes.values()) { + const id = getID(node, schema); + const children = descendantsByParent.get(id); + if (children) { + for (const child of children.values()) { + const childID = getID(child, schema); + nextLevel.set(childID, child); + } + } + } + return createLevels({ descendantsByParent, levels, currentNodes: nextLevel, schema }); +}; + +interface TreeExpectation { + origin: NodeID; + nodeExpectations: NodeExpectations; +} + +interface NodeExpectations { + ancestors?: number; + descendants?: number; + descendantLevels?: number; +} + +interface APITree { + // entries closer to the beginning of the array are more direct parents of the origin aka + // ancestors[0] = the origin's parent, ancestors[1] = the origin's grandparent + ancestors: ResolverNode[]; + // if no ancestors were retrieved then the origin will be undefined + origin: ResolverNode | undefined; + descendantLevels: Array>; + nodeExpectations: NodeExpectations; +} + +/** + * Represents a utility structure for making it easier to perform expect calls on the response + * from the /tree api. This can represent multiple trees, since the tree api can return multiple trees. + */ +export interface APIResponse { + nodesByID: Map; + trees: Map; + allNodes: ResolverNode[]; +} + +/** + * Gets the ID field from a resolver node. Throws an error if the ID doesn't exist. + * + * @param node a resolver node + * @param schema the schema that was used to retrieve this resolver node + */ +export const getID = (node: ResolverNode | undefined, schema: Schema): NodeID => { + const id = firstNonNullValue(node?.data[schema.id]); + if (!id) { + throw new Error(`Unable to find id ${schema.id} in node: ${JSON.stringify(node)}`); + } + return id; +}; + +const getParentInternal = (node: ResolverNode | undefined, schema: Schema): NodeID | undefined => { + if (node) { + return firstNonNullValue(node?.data[schema.parent]); + } + return undefined; +}; + +/** + * Gets the parent ID field from a resolver node. Throws an error if the ID doesn't exist. + * + * @param node a resolver node + * @param schema the schema that was used to retrieve this resolver node + */ +export const getParent = (node: ResolverNode | undefined, schema: Schema): NodeID => { + const parent = getParentInternal(node, schema); + if (!parent) { + throw new Error(`Unable to find parent ${schema.parent} in node: ${JSON.stringify(node)}`); + } + return parent; +}; + +/** + * Reformats the tree's response to make it easier to perform testing on the results. + * + * @param treeExpectations the node IDs used to retrieve the trees and the expected number of ancestors/descendants in the + * resulting trees + * @param nodes the response from the tree api + * @param schema the schema used when calling the tree api + */ +const createTreeFromResponse = ( + treeExpectations: TreeExpectation[], + nodes: ResolverNode[], + schema: Schema +) => { + const nodesByID = new Map(); + const nodesByParent = new Map>(); + + for (const node of nodes) { + const id = getID(node, schema); + const parent = getParentInternal(node, schema); + + nodesByID.set(id, node); + + if (parent) { + let groupedChildren = nodesByParent.get(parent); + if (!groupedChildren) { + groupedChildren = new Map(); + nodesByParent.set(parent, groupedChildren); + } + + groupedChildren.set(id, node); + } + } + + const trees: Map = new Map(); + + for (const expectation of treeExpectations) { + const descendantLevels = createLevels({ + descendantsByParent: nodesByParent, + levels: [], + currentNodes: nodesByParent.get(expectation.origin), + schema, + }); + + const ancestors: ResolverNode[] = []; + const originNode = nodesByID.get(expectation.origin); + if (originNode) { + let currentID: NodeID | undefined = getParentInternal(originNode, schema); + // construct an array with all the ancestors from the response. We'll use this to verify that + // all the expected ancestors were returned in the response. + while (currentID !== undefined) { + const parentNode = nodesByID.get(currentID); + if (parentNode) { + ancestors.push(parentNode); + } + currentID = getParentInternal(parentNode, schema); + } + } + + trees.set(expectation.origin, { + ancestors, + origin: originNode, + descendantLevels, + nodeExpectations: expectation.nodeExpectations, + }); + } + + return { + nodesByID, + trees, + allNodes: nodes, + }; +}; + +const verifyAncestry = ({ + responseTrees, + schema, + genTree, +}: { + responseTrees: APIResponse; + schema: Schema; + genTree: Tree; +}) => { + const allGenNodes = new Map([ + ...genTree.ancestry, + ...genTree.children, + [genTree.origin.id, genTree.origin], + ]); + + for (const tree of responseTrees.trees.values()) { + if (tree.nodeExpectations.ancestors !== undefined) { + expect(tree.ancestors.length).to.be(tree.nodeExpectations.ancestors); + } + + if (tree.origin !== undefined) { + // make sure the origin node from the request exists in the generated data and has the same fields + const originID = getID(tree.origin, schema); + const originParentID = getParent(tree.origin, schema); + expect(tree.origin.id).to.be(originID); + expect(tree.origin.parent).to.be(originParentID); + expect(allGenNodes.get(String(originID))?.id).to.be(String(originID)); + expect(allGenNodes.get(String(originParentID))?.id).to.be(String(originParentID)); + expect(originID).to.be(entityIDSafeVersion(allGenNodes.get(String(originID))!.lifecycle[0])); + expect(originParentID).to.be( + parentEntityIDSafeVersion(allGenNodes.get(String(originID))!.lifecycle[0]) + ); + // make sure the lifecycle events are sorted by timestamp in ascending order because the + // event that will be returned that we need to compare to should be the earliest event + // found + const originLifecycleSorted = [...allGenNodes.get(String(originID))!.lifecycle].sort( + (a: Event, b: Event) => { + const aTime: number | undefined = timestampSafeVersion(a); + const bTime = timestampSafeVersion(b); + if (aTime !== undefined && bTime !== undefined) { + return aTime - bTime; + } else { + return 0; + } + } + ); + + const ts = timestampAsDateSafeVersion(tree.origin?.data); + expect(ts).to.not.be(undefined); + expect(ts).to.eql(timestampAsDateSafeVersion(originLifecycleSorted[0])); + } + + // check the constructed ancestors array to see if we're missing any nodes in the ancestry + for (let i = 0; i < tree.ancestors.length; i++) { + const id = getID(tree.ancestors[i], schema); + const parent = getParentInternal(tree.ancestors[i], schema); + // only compare to the parent if this is not the last entry in the array + if (i < tree.ancestors.length - 1) { + // the current node's parent ID should match the parent's ID field + expect(parent).to.be(getID(tree.ancestors[i + 1], schema)); + expect(parent).to.not.be(undefined); + expect(tree.ancestors[i].parent).to.not.be(undefined); + expect(tree.ancestors[i].parent).to.be(parent); + } + // the current node's ID must exist in the generated tree + expect(allGenNodes.get(String(id))?.id).to.be(id); + expect(tree.ancestors[i].id).to.be(id); + } + } +}; + +const verifyChildren = ({ + responseTrees, + schema, + genTree, +}: { + responseTrees: APIResponse; + schema: Schema; + genTree: Tree; +}) => { + const allGenNodes = new Map([ + ...genTree.ancestry, + ...genTree.children, + [genTree.origin.id, genTree.origin], + ]); + for (const tree of responseTrees.trees.values()) { + if (tree.nodeExpectations.descendantLevels !== undefined) { + expect(tree.nodeExpectations.descendantLevels).to.be(tree.descendantLevels.length); + } + let totalDescendants = 0; + + for (const level of tree.descendantLevels) { + for (const node of level.values()) { + totalDescendants += 1; + const id = getID(node, schema); + const parent = getParent(node, schema); + const genNode = allGenNodes.get(String(id)); + expect(id).to.be(node.id); + expect(parent).to.be(node.parent); + expect(node.parent).to.not.be(undefined); + // make sure the id field is the same in the returned node as the generated one + expect(id).to.be(entityIDSafeVersion(genNode!.lifecycle[0])); + // make sure the parent field is the same in the returned node as the generated one + expect(parent).to.be(parentEntityIDSafeVersion(genNode!.lifecycle[0])); + } + } + if (tree.nodeExpectations.descendants !== undefined) { + expect(tree.nodeExpectations.descendants).to.be(totalDescendants); + } + } +}; + +const verifyStats = ({ + responseTrees, + relatedEventsCategories, +}: { + responseTrees: APIResponse; + relatedEventsCategories: RelatedEventInfo[]; +}) => { + for (const node of responseTrees.allNodes) { + let totalExpEvents = 0; + for (const cat of relatedEventsCategories) { + const ecsCategories = categoryMapping[cat.category]; + if (Array.isArray(ecsCategories)) { + // if there are multiple ecs categories used to define a related event, the count for all of them should be the same + // and they should equal what is defined in the categories used to generate the related events + for (const ecsCat of ecsCategories) { + expect(node.stats.byCategory[ecsCat]).to.be(cat.count); + } + } else { + expect(node.stats.byCategory[ecsCategories]).to.be(cat.count); + } + + totalExpEvents += cat.count; + } + expect(node.stats.total).to.be(totalExpEvents); + } +}; + +/** + * Verify the ancestry of multiple trees. + * + * @param expectations array of expectations based on the origin that built a particular tree + * @param response the nodes returned from the api + * @param schema the schema fields passed to the tree api + * @param genTree the generated tree that was inserted in Elasticsearch that we are querying + * @param relatedEventsCategories an optional array to instruct the verification to check the stats + * on each node returned + */ +export const verifyTree = ({ + expectations, + response, + schema, + genTree, + relatedEventsCategories, +}: { + expectations: TreeExpectation[]; + response: ResolverNode[]; + schema: Schema; + genTree: Tree; + relatedEventsCategories?: RelatedEventInfo[]; +}) => { + const responseTrees = createTreeFromResponse(expectations, response, schema); + verifyAncestry({ responseTrees, schema, genTree }); + verifyChildren({ responseTrees, schema, genTree }); + if (relatedEventsCategories !== undefined) { + verifyStats({ responseTrees, relatedEventsCategories }); + } +}; + /** * Creates the ancestry array based on an array of events. The order of the ancestry array will match the order * of the events passed in. @@ -44,6 +390,7 @@ export const createAncestryArray = (events: Event[]) => { /** * Check that the given lifecycle is in the resolver tree's corresponding map * + * @deprecated use verifyTree * @param node a lifecycle node containing the start and end events for a node * @param nodeMap a map of entity_ids to nodes to look for the passed in `node` */ @@ -59,12 +406,13 @@ const expectLifecycleNodeInMap = ( /** * Verify that all the ancestor nodes are valid and optionally have parents. * + * @deprecated use verifyTree * @param ancestors an array of ancestors * @param tree the generated resolver tree as the source of truth * @param verifyLastParent a boolean indicating whether to check the last ancestor. If the ancestors array intentionally * does not contain all the ancestors, the last one will not have the parent */ -export const verifyAncestry = ( +export const checkAncestryFromEntityTreeAPI = ( ancestors: SafeResolverLifecycleNode[], tree: Tree, verifyLastParent: boolean @@ -114,6 +462,7 @@ export const verifyAncestry = ( /** * Retrieves the most distant ancestor in the given array. * + * @deprecated use verifyTree * @param ancestors an array of ancestor nodes */ export const retrieveDistantAncestor = (ancestors: SafeResolverLifecycleNode[]) => { @@ -137,12 +486,13 @@ export const retrieveDistantAncestor = (ancestors: SafeResolverLifecycleNode[]) /** * Verify that the children nodes are correct * + * @deprecated use verifyTree * @param children the children nodes * @param tree the generated resolver tree as the source of truth * @param numberOfParents an optional number to compare that are a certain number of parents in the children array * @param childrenPerParent an optional number to compare that there are a certain number of children for each parent */ -export const verifyChildren = ( +export const verifyChildrenFromEntityTreeAPI = ( children: SafeResolverChildNode[], tree: Tree, numberOfParents?: number, @@ -200,10 +550,11 @@ export const compareArrays = ( /** * Verifies that the stats received from ES for a node reflect the categories of events that the generator created. * + * @deprecated use verifyTree * @param relatedEvents the related events received for a particular node * @param categories the related event info used when generating the resolver tree */ -export const verifyStats = ( +export const verifyEntityTreeStats = ( stats: ResolverNodeStats | undefined, categories: RelatedEventInfo[], relatedAlerts: number @@ -225,12 +576,12 @@ export const verifyStats = ( totalExpEvents += cat.count; } expect(stats?.events.total).to.be(totalExpEvents); - expect(stats?.totalAlerts); }; /** * A helper function for verifying the stats information an array of nodes. * + * @deprecated use verifyTree * @param nodes an array of lifecycle nodes that should have a stats field defined * @param categories the related event info used when generating the resolver tree */ @@ -240,6 +591,6 @@ export const verifyLifecycleStats = ( relatedAlerts: number ) => { for (const node of nodes) { - verifyStats(node.stats, categories, relatedAlerts); + verifyEntityTreeStats(node.stats, categories, relatedAlerts); } }; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts index ecfc1ef5bb7f..0ba5460f09d9 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts @@ -12,6 +12,7 @@ export default function (providerContext: FtrProviderContext) { loadTestFile(require.resolve('./entity_id')); loadTestFile(require.resolve('./entity')); loadTestFile(require.resolve('./children')); + loadTestFile(require.resolve('./tree_entity_id')); loadTestFile(require.resolve('./tree')); loadTestFile(require.resolve('./alerts')); loadTestFile(require.resolve('./events')); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts index 7a95bf7bab88..646a666629ac 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts @@ -4,31 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ import expect from '@kbn/expect'; +import { getNameField } from '../../../../plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch'; +import { Schema } from '../../../../plugins/security_solution/server/endpoint/routes/resolver/tree/utils'; +import { ResolverNode } from '../../../../plugins/security_solution/common/endpoint/types'; import { - SafeResolverAncestry, - SafeResolverChildren, - SafeResolverTree, - SafeLegacyEndpointEvent, -} from '../../../../plugins/security_solution/common/endpoint/types'; -import { parentEntityIDSafeVersion } from '../../../../plugins/security_solution/common/endpoint/models/event'; + parentEntityIDSafeVersion, + timestampSafeVersion, +} from '../../../../plugins/security_solution/common/endpoint/models/event'; import { FtrProviderContext } from '../../ftr_provider_context'; import { Tree, RelatedEventCategory, } from '../../../../plugins/security_solution/common/endpoint/generate_data'; import { Options, GeneratedTrees } from '../../services/resolver'; -import { - compareArrays, - verifyAncestry, - retrieveDistantAncestor, - verifyChildren, - verifyLifecycleStats, - verifyStats, -} from './common'; +import { verifyTree } from './common'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); const resolver = getService('resolverGenerator'); const relatedEventsToGen = [ @@ -52,322 +44,641 @@ export default function ({ getService }: FtrProviderContext) { ancestryArraySize: 2, }; + const schemaWithAncestry: Schema = { + ancestry: 'process.Ext.ancestry', + id: 'process.entity_id', + parent: 'process.parent.entity_id', + }; + + const schemaWithoutAncestry: Schema = { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + }; + + const schemaWithName: Schema = { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + name: 'process.name', + }; + describe('Resolver tree', () => { before(async () => { - await esArchiver.load('endpoint/resolver/api_feature'); resolverTrees = await resolver.createTrees(treeOptions); // we only requested a single alert so there's only 1 tree tree = resolverTrees.trees[0]; }); after(async () => { await resolver.deleteData(resolverTrees); - // this unload is for an endgame-* index so it does not use data streams - await esArchiver.unload('endpoint/resolver/api_feature'); }); - describe('ancestry events route', () => { - describe('legacy events', () => { - const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; - const entityID = '94042'; - - it('should return details for the root node', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get( - `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=5` - ) - .expect(200); - expect(body.ancestors[0].lifecycle.length).to.eql(2); - expect(body.ancestors.length).to.eql(2); - expect(body.nextAncestor).to.eql(null); - }); - - it('should have a populated next parameter', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get( - `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` - ) - .expect(200); - expect(body.nextAncestor).to.eql('94041'); + describe('ancestry events', () => { + it('should return the correct ancestor nodes for the tree', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 9, + schema: schemaWithAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [{ origin: tree.origin.id, nodeExpectations: { ancestors: 5 } }], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); + }); - it('should handle an ancestors param request', async () => { - let { body }: { body: SafeResolverAncestry } = await supertest - .get( - `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` - ) - .expect(200); - const next = body.nextAncestor; + it('should handle an invalid id', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 9, + schema: schemaWithAncestry, + nodes: ['bogus id'], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + expect(body).to.be.empty(); + }); - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${next}/ancestry?legacyEndpointID=${endpointID}&ancestors=1` - ) - .expect(200)); - expect(body.ancestors[0].lifecycle.length).to.eql(1); - expect(body.nextAncestor).to.eql(null); + it('should return a subset of the ancestors', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + // 3 ancestors means 1 origin and 2 ancestors of the origin + ancestors: 3, + schema: schemaWithAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [{ origin: tree.origin.id, nodeExpectations: { ancestors: 2 } }], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); }); - describe('endpoint events', () => { - it('should return the origin node at the front of the array', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=9`) - .expect(200); - expect(body.ancestors[0].entityID).to.eql(tree.origin.id); + it('should return ancestors without the ancestry array', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [{ origin: tree.origin.id, nodeExpectations: { ancestors: 5 } }], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('should return details for the root node', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=9`) - .expect(200); - // the tree we generated had 5 ancestors + 1 origin node - expect(body.ancestors.length).to.eql(6); - expect(body.ancestors[0].entityID).to.eql(tree.origin.id); - verifyAncestry(body.ancestors, tree, true); - expect(body.nextAncestor).to.eql(null); + it('should respect the time range specified and only return the origin node', async () => { + const from = new Date( + timestampSafeVersion(tree.origin.lifecycle[0]) ?? new Date() + ).toISOString(); + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from, + to: from, + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [{ origin: tree.origin.id, nodeExpectations: { ancestors: 0 } }], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('should handle an invalid id', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/alskdjflasj/ancestry`) - .expect(200); - expect(body.ancestors).to.be.empty(); - expect(body.nextAncestor).to.eql(null); + it('should support returning multiple ancestor trees when multiple nodes are requested', async () => { + // There should be 2 levels of descendants under the origin, grab the bottom one, and the first node's id + const bottomMostDescendant = Array.from(tree.childrenLevels[1].values())[0].id; + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id, bottomMostDescendant], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 5 ancestors above the origin + { origin: tree.origin.id, nodeExpectations: { ancestors: 5 } }, + // there are 2 levels below the origin so the bottom node's ancestry should be + // all the ancestors (5) + one level + the origin = 7 + { origin: bottomMostDescendant, nodeExpectations: { ancestors: 7 } }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('should have a populated next parameter', async () => { - const { body }: { body: SafeResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=2`) - .expect(200); - // it should have 2 ancestors + 1 origin - expect(body.ancestors.length).to.eql(3); - verifyAncestry(body.ancestors, tree, false); - const distantGrandparent = retrieveDistantAncestor(body.ancestors); - expect(body.nextAncestor).to.eql( - parentEntityIDSafeVersion(distantGrandparent.lifecycle[0]) - ); + it('should return a single ancestry when two nodes a the same level and from same parent are requested', async () => { + // there are 2 levels after the origin, let's get the first level, there will be three + // children so get the left and right most ones + const level0Nodes = Array.from(tree.childrenLevels[0].values()); + const leftNode = level0Nodes[0].id; + const rightNode = level0Nodes[2].id; + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [leftNode, rightNode], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // We should be 1 level below the origin so the node's ancestry should be + // all the ancestors (5) + the origin = 6 + { origin: leftNode, nodeExpectations: { ancestors: 6 } }, + // these nodes should be at the same level so the ancestors should be the same number + { origin: rightNode, nodeExpectations: { ancestors: 6 } }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('should handle multiple ancestor requests', async () => { - let { body }: { body: SafeResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=3`) - .expect(200); - expect(body.ancestors.length).to.eql(4); - const next = body.nextAncestor; - - ({ body } = await supertest - .get(`/api/endpoint/resolver/${next}/ancestry?ancestors=1`) - .expect(200)); - expect(body.ancestors.length).to.eql(2); - verifyAncestry(body.ancestors, tree, true); - // the highest node in the generated tree will not have a parent ID which causes the server to return - // without setting the pagination so nextAncestor will be null - expect(body.nextAncestor).to.eql(null); - }); + it('should not return any nodes when the search index does not have any data', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['metrics-*'], + }) + .expect(200); + expect(body).to.be.empty(); }); }); - describe('children route', () => { - describe('legacy events', () => { - const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; - const entityID = '94041'; - - it('returns child process lifecycle events', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${entityID}/children?legacyEndpointID=${endpointID}`) - .expect(200); - expect(body.childNodes.length).to.eql(1); - expect(body.childNodes[0].lifecycle.length).to.eql(2); - expect( - // for some reason the ts server doesn't think `endgame` exists even though we're using ResolverEvent - // here, so to avoid it complaining we'll just force it - (body.childNodes[0].lifecycle[0] as SafeLegacyEndpointEvent).endgame.unique_pid - ).to.eql(94042); + describe('descendant events', () => { + it('returns all descendants for the origin without using the ancestry field', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 2, + ancestors: 0, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 2 levels in the descendant part of the tree and 3 nodes for each + // descendant = 3 children for the origin + 3 children for each of the origin's children = 12 + { origin: tree.origin.id, nodeExpectations: { descendants: 12, descendantLevels: 2 } }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('returns multiple levels of child process lifecycle events', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/93802/children?legacyEndpointID=${endpointID}&children=10`) - .expect(200); - expect(body.childNodes.length).to.eql(10); - expect(body.nextChild).to.be(null); - expect(body.childNodes[0].lifecycle.length).to.eql(1); - expect( - // for some reason the ts server doesn't think `endgame` exists even though we're using ResolverEvent - // here, so to avoid it complaining we'll just force it - (body.childNodes[0].lifecycle[0] as SafeLegacyEndpointEvent).endgame.unique_pid - ).to.eql(93932); + it('returns all descendants for the origin using the ancestry field', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + // should be ignored when using the ancestry array + descendantLevels: 0, + ancestors: 0, + schema: schemaWithAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 2 levels in the descendant part of the tree and 3 nodes for each + // descendant = 3 children for the origin + 3 children for each of the origin's children = 12 + { origin: tree.origin.id, nodeExpectations: { descendants: 12, descendantLevels: 2 } }, + ], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); + }); - it('returns no values when there is no more data', async () => { - let { body }: { body: SafeResolverChildren } = await supertest - .get( - // there should only be a single child for this node - `/api/endpoint/resolver/94041/children?legacyEndpointID=${endpointID}&children=1` - ) - .expect(200); - expect(body.nextChild).to.not.be(null); - - ({ body } = await supertest - .get( - `/api/endpoint/resolver/94041/children?legacyEndpointID=${endpointID}&afterChild=${body.nextChild}` - ) - .expect(200)); - expect(body.childNodes).be.empty(); - expect(body.nextChild).to.eql(null); - }); + it('should handle an invalid id', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 100, + ancestors: 0, + schema: schemaWithAncestry, + nodes: ['bogus id'], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + expect(body).to.be.empty(); + }); - it('returns the first page of information when the cursor is invalid', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get( - `/api/endpoint/resolver/${entityID}/children?legacyEndpointID=${endpointID}&afterChild=blah` - ) - .expect(200); - expect(body.childNodes.length).to.eql(1); - expect(body.nextChild).to.be(null); + it('returns a single generation of children', async () => { + // this gets a node should have 3 children which were created in succession so that the timestamps + // are ordered correctly to be retrieved in a single call + const childID = Array.from(tree.childrenLevels[0].values())[0].id; + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 1, + ancestors: 0, + schema: schemaWithoutAncestry, + nodes: [childID], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // a single generation should be three nodes + { origin: childID, nodeExpectations: { descendants: 3, descendantLevels: 1 } }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); - it('errors on invalid pagination values', async () => { - await supertest.get(`/api/endpoint/resolver/${entityID}/children?children=0`).expect(400); - await supertest - .get(`/api/endpoint/resolver/${entityID}/children?children=20000`) - .expect(400); - await supertest - .get(`/api/endpoint/resolver/${entityID}/children?children=-1`) - .expect(400); + it('should support returning multiple descendant trees when multiple nodes are requested', async () => { + // there are 2 levels after the origin, let's get the first level, there will be three + // children so get the left and right most ones + const level0Nodes = Array.from(tree.childrenLevels[0].values()); + const leftNodeID = level0Nodes[0].id; + const rightNodeID = level0Nodes[2].id; + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 6, + descendantLevels: 0, + ancestors: 0, + schema: schemaWithAncestry, + nodes: [leftNodeID, rightNodeID], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + { origin: leftNodeID, nodeExpectations: { descendantLevels: 1, descendants: 3 } }, + { origin: rightNodeID, nodeExpectations: { descendantLevels: 1, descendants: 3 } }, + ], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); + }); - it('returns empty events without a matching entity id', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/5555/children`) - .expect(200); - expect(body.nextChild).to.eql(null); - expect(body.childNodes).to.be.empty(); + it('should support returning multiple descendant trees when multiple nodes are requested at different levels', async () => { + const originParent = parentEntityIDSafeVersion(tree.origin.lifecycle[0]) ?? ''; + expect(originParent).to.not.be(''); + const originGrandparent = + parentEntityIDSafeVersion(tree.ancestry.get(originParent)!.lifecycle[0]) ?? ''; + expect(originGrandparent).to.not.be(''); + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 2, + descendantLevels: 0, + ancestors: 0, + schema: schemaWithAncestry, + nodes: [tree.origin.id, originGrandparent], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + { origin: tree.origin.id, nodeExpectations: { descendantLevels: 1, descendants: 1 } }, + // the origin's grandparent should only have the origin's parent as a descendant + { + origin: originGrandparent, + nodeExpectations: { descendantLevels: 1, descendants: 1 }, + }, + ], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); + }); - it('returns empty events with an invalid endpoint id', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${entityID}/children?legacyEndpointID=foo`) - .expect(200); - expect(body.nextChild).to.eql(null); - expect(body.childNodes).to.be.empty(); + it('should support returning multiple descendant trees when multiple nodes are requested at different levels without ancestry field', async () => { + const originParent = parentEntityIDSafeVersion(tree.origin.lifecycle[0]) ?? ''; + expect(originParent).to.not.be(''); + const originGrandparent = + parentEntityIDSafeVersion(tree.ancestry.get(originParent)!.lifecycle[0]) ?? ''; + expect(originGrandparent).to.not.be(''); + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 6, + descendantLevels: 1, + ancestors: 0, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id, originGrandparent], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + { origin: tree.origin.id, nodeExpectations: { descendantLevels: 1, descendants: 3 } }, + // the origin's grandparent should only have the origin's parent as a descendant + { + origin: originGrandparent, + nodeExpectations: { descendantLevels: 1, descendants: 1 }, + }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); }); - describe('endpoint events', () => { - it('returns all children for the origin', async () => { - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/children?children=100`) - .expect(200); - // there are 2 levels in the children part of the tree and 3 nodes for each = - // 3 children for the origin + 3 children for each of the origin's children = 12 - expect(body.childNodes.length).to.eql(12); - // there will be 4 parents, the origin of the tree, and it's 3 children - verifyChildren(body.childNodes, tree, 4, 3); - expect(body.nextChild).to.eql(null); + it('should respect the time range specified and only return one descendant', async () => { + const level0Node = Array.from(tree.childrenLevels[0].values())[0]; + const end = new Date( + timestampSafeVersion(level0Node.lifecycle[0]) ?? new Date() + ).toISOString(); + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 5, + ancestors: 0, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: end, + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + { origin: tree.origin.id, nodeExpectations: { descendantLevels: 1, descendants: 1 } }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, }); + }); + }); - it('returns a single generation of children', async () => { - // this gets a node should have 3 children which were created in succession so that the timestamps - // are ordered correctly to be retrieved in a single call - const distantChildEntityID = Array.from(tree.childrenLevels[0].values())[0].id; - const { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${distantChildEntityID}/children?children=3`) - .expect(200); - expect(body.childNodes.length).to.eql(3); - verifyChildren(body.childNodes, tree, 1, 3); - expect(body.nextChild).to.not.eql(null); + describe('ancestry and descendants', () => { + it('returns all descendants and ancestors without the ancestry field and they should have the name field', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 10, + ancestors: 50, + schema: schemaWithName, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 2 levels in the descendant part of the tree and 3 nodes for each + // descendant = 3 children for the origin + 3 children for each of the origin's children = 12 + { + origin: tree.origin.id, + nodeExpectations: { descendants: 12, descendantLevels: 2, ancestors: 5 }, + }, + ], + response: body, + schema: schemaWithName, + genTree: tree, + relatedEventsCategories: relatedEventsToGen, }); - it('paginates the children', async () => { - // this gets a node should have 3 children which were created in succession so that the timestamps - // are ordered correctly to be retrieved in a single call - const distantChildEntityID = Array.from(tree.childrenLevels[0].values())[0].id; - let { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${distantChildEntityID}/children?children=1`) - .expect(200); - expect(body.childNodes.length).to.eql(1); - verifyChildren(body.childNodes, tree, 1, 1); - expect(body.nextChild).to.not.be(null); - - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${distantChildEntityID}/children?children=2&afterChild=${body.nextChild}` - ) - .expect(200)); - expect(body.childNodes.length).to.eql(2); - verifyChildren(body.childNodes, tree, 1, 2); - expect(body.nextChild).to.not.be(null); + for (const node of body) { + expect(node.name).to.be(getNameField(node.data, schemaWithName)); + expect(node.name).to.not.be(undefined); + } + }); - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${distantChildEntityID}/children?children=2&afterChild=${body.nextChild}` - ) - .expect(200)); - expect(body.childNodes.length).to.eql(0); - expect(body.nextChild).to.be(null); + it('returns all descendants and ancestors without the ancestry field', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 10, + ancestors: 50, + schema: schemaWithoutAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 2 levels in the descendant part of the tree and 3 nodes for each + // descendant = 3 children for the origin + 3 children for each of the origin's children = 12 + { + origin: tree.origin.id, + nodeExpectations: { descendants: 12, descendantLevels: 2, ancestors: 5 }, + }, + ], + response: body, + schema: schemaWithoutAncestry, + genTree: tree, + relatedEventsCategories: relatedEventsToGen, }); - it('gets all children in two queries', async () => { - // should get all the children of the origin - let { body }: { body: SafeResolverChildren } = await supertest - .get(`/api/endpoint/resolver/${tree.origin.id}/children?children=3`) - .expect(200); - expect(body.childNodes.length).to.eql(3); - verifyChildren(body.childNodes, tree); - expect(body.nextChild).to.not.be(null); - const firstNodes = [...body.childNodes]; - - ({ body } = await supertest - .get( - `/api/endpoint/resolver/${tree.origin.id}/children?children=10&afterChild=${body.nextChild}` - ) - .expect(200)); - expect(body.childNodes.length).to.eql(9); - // put all the results together and we should have all the children - verifyChildren([...firstNodes, ...body.childNodes], tree, 4, 3); - expect(body.nextChild).to.be(null); - }); + for (const node of body) { + expect(node.name).to.be(getNameField(node.data, schemaWithoutAncestry)); + expect(node.name).to.be(undefined); + } }); - }); - - describe('tree api', () => { - describe('legacy events', () => { - const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; - it('returns ancestors, events, children, and current process lifecycle', async () => { - const { body }: { body: SafeResolverTree } = await supertest - .get(`/api/endpoint/resolver/93933?legacyEndpointID=${endpointID}`) - .expect(200); - expect(body.ancestry.nextAncestor).to.equal(null); - expect(body.children.nextChild).to.equal(null); - expect(body.children.childNodes.length).to.equal(0); - expect(body.lifecycle.length).to.equal(2); + it('returns all descendants and ancestors with the ancestry field', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 100, + descendantLevels: 10, + ancestors: 50, + schema: schemaWithAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + verifyTree({ + expectations: [ + // there are 2 levels in the descendant part of the tree and 3 nodes for each + // descendant = 3 children for the origin + 3 children for each of the origin's children = 12 + { + origin: tree.origin.id, + nodeExpectations: { descendants: 12, descendantLevels: 2, ancestors: 5 }, + }, + ], + response: body, + schema: schemaWithAncestry, + genTree: tree, + relatedEventsCategories: relatedEventsToGen, }); - }); - - describe('endpoint events', () => { - it('returns a tree', async () => { - const { body }: { body: SafeResolverTree } = await supertest - .get( - `/api/endpoint/resolver/${tree.origin.id}?children=100&ancestors=5&events=5&alerts=5` - ) - .expect(200); - - expect(body.children.nextChild).to.equal(null); - expect(body.children.childNodes.length).to.equal(12); - verifyChildren(body.children.childNodes, tree, 4, 3); - verifyLifecycleStats(body.children.childNodes, relatedEventsToGen, relatedAlerts); - expect(body.ancestry.nextAncestor).to.equal(null); - verifyAncestry(body.ancestry.ancestors, tree, true); - verifyLifecycleStats(body.ancestry.ancestors, relatedEventsToGen, relatedAlerts); - - expect(body.relatedAlerts.nextAlert).to.equal(null); - compareArrays(tree.origin.relatedAlerts, body.relatedAlerts.alerts, true); + for (const node of body) { + expect(node.name).to.be(getNameField(node.data, schemaWithAncestry)); + expect(node.name).to.be(undefined); + } + }); - compareArrays(tree.origin.lifecycle, body.lifecycle, true); - verifyStats(body.stats, relatedEventsToGen, relatedAlerts); + it('returns an empty response when limits are zero', async () => { + const { body }: { body: ResolverNode[] } = await supertest + .post('/api/endpoint/resolver/tree') + .set('kbn-xsrf', 'xxx') + .send({ + descendants: 0, + descendantLevels: 0, + ancestors: 0, + schema: schemaWithAncestry, + nodes: [tree.origin.id], + timerange: { + from: tree.startTime.toISOString(), + to: tree.endTime.toISOString(), + }, + indexPatterns: ['logs-*'], + }) + .expect(200); + expect(body).to.be.empty(); + verifyTree({ + expectations: [ + { + origin: tree.origin.id, + nodeExpectations: { descendants: 0, descendantLevels: 0, ancestors: 0 }, + }, + ], + response: body, + schema: schemaWithAncestry, + genTree: tree, }); }); }); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree_entity_id.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree_entity_id.ts new file mode 100644 index 000000000000..39cce77b8cc9 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree_entity_id.ts @@ -0,0 +1,375 @@ +/* + * 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 expect from '@kbn/expect'; +import { + SafeResolverAncestry, + SafeResolverChildren, + SafeResolverTree, + SafeLegacyEndpointEvent, +} from '../../../../plugins/security_solution/common/endpoint/types'; +import { parentEntityIDSafeVersion } from '../../../../plugins/security_solution/common/endpoint/models/event'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { + Tree, + RelatedEventCategory, +} from '../../../../plugins/security_solution/common/endpoint/generate_data'; +import { Options, GeneratedTrees } from '../../services/resolver'; +import { + compareArrays, + checkAncestryFromEntityTreeAPI, + retrieveDistantAncestor, + verifyChildrenFromEntityTreeAPI, + verifyLifecycleStats, + verifyEntityTreeStats, +} from './common'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const resolver = getService('resolverGenerator'); + + const relatedEventsToGen = [ + { category: RelatedEventCategory.Driver, count: 2 }, + { category: RelatedEventCategory.File, count: 1 }, + { category: RelatedEventCategory.Registry, count: 1 }, + ]; + const relatedAlerts = 4; + let resolverTrees: GeneratedTrees; + let tree: Tree; + const treeOptions: Options = { + ancestors: 5, + relatedEvents: relatedEventsToGen, + relatedAlerts, + children: 3, + generations: 2, + percentTerminated: 100, + percentWithRelated: 100, + numTrees: 1, + alwaysGenMaxChildrenPerNode: true, + ancestryArraySize: 2, + }; + + describe('Resolver entity tree api', () => { + before(async () => { + await esArchiver.load('endpoint/resolver/api_feature'); + resolverTrees = await resolver.createTrees(treeOptions); + // we only requested a single alert so there's only 1 tree + tree = resolverTrees.trees[0]; + }); + after(async () => { + await resolver.deleteData(resolverTrees); + // this unload is for an endgame-* index so it does not use data streams + await esArchiver.unload('endpoint/resolver/api_feature'); + }); + + describe('ancestry events route', () => { + describe('legacy events', () => { + const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; + const entityID = '94042'; + + it('should return details for the root node', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get( + `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=5` + ) + .expect(200); + expect(body.ancestors[0].lifecycle.length).to.eql(2); + expect(body.ancestors.length).to.eql(2); + expect(body.nextAncestor).to.eql(null); + }); + + it('should have a populated next parameter', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get( + `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` + ) + .expect(200); + expect(body.nextAncestor).to.eql('94041'); + }); + + it('should handle an ancestors param request', async () => { + let { body }: { body: SafeResolverAncestry } = await supertest + .get( + `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` + ) + .expect(200); + const next = body.nextAncestor; + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/${next}/ancestry?legacyEndpointID=${endpointID}&ancestors=1` + ) + .expect(200)); + expect(body.ancestors[0].lifecycle.length).to.eql(1); + expect(body.nextAncestor).to.eql(null); + }); + }); + + describe('endpoint events', () => { + it('should return the origin node at the front of the array', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=9`) + .expect(200); + expect(body.ancestors[0].entityID).to.eql(tree.origin.id); + }); + + it('should return details for the root node', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=9`) + .expect(200); + // the tree we generated had 5 ancestors + 1 origin node + expect(body.ancestors.length).to.eql(6); + expect(body.ancestors[0].entityID).to.eql(tree.origin.id); + checkAncestryFromEntityTreeAPI(body.ancestors, tree, true); + expect(body.nextAncestor).to.eql(null); + }); + + it('should handle an invalid id', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get(`/api/endpoint/resolver/alskdjflasj/ancestry`) + .expect(200); + expect(body.ancestors).to.be.empty(); + expect(body.nextAncestor).to.eql(null); + }); + + it('should have a populated next parameter', async () => { + const { body }: { body: SafeResolverAncestry } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=2`) + .expect(200); + // it should have 2 ancestors + 1 origin + expect(body.ancestors.length).to.eql(3); + checkAncestryFromEntityTreeAPI(body.ancestors, tree, false); + const distantGrandparent = retrieveDistantAncestor(body.ancestors); + expect(body.nextAncestor).to.eql( + parentEntityIDSafeVersion(distantGrandparent.lifecycle[0]) + ); + }); + + it('should handle multiple ancestor requests', async () => { + let { body }: { body: SafeResolverAncestry } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/ancestry?ancestors=3`) + .expect(200); + expect(body.ancestors.length).to.eql(4); + const next = body.nextAncestor; + + ({ body } = await supertest + .get(`/api/endpoint/resolver/${next}/ancestry?ancestors=1`) + .expect(200)); + expect(body.ancestors.length).to.eql(2); + checkAncestryFromEntityTreeAPI(body.ancestors, tree, true); + // the highest node in the generated tree will not have a parent ID which causes the server to return + // without setting the pagination so nextAncestor will be null + expect(body.nextAncestor).to.eql(null); + }); + }); + }); + + describe('children route', () => { + describe('legacy events', () => { + const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; + const entityID = '94041'; + + it('returns child process lifecycle events', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${entityID}/children?legacyEndpointID=${endpointID}`) + .expect(200); + expect(body.childNodes.length).to.eql(1); + expect(body.childNodes[0].lifecycle.length).to.eql(2); + expect( + // for some reason the ts server doesn't think `endgame` exists even though we're using ResolverEvent + // here, so to avoid it complaining we'll just force it + (body.childNodes[0].lifecycle[0] as SafeLegacyEndpointEvent).endgame.unique_pid + ).to.eql(94042); + }); + + it('returns multiple levels of child process lifecycle events', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/93802/children?legacyEndpointID=${endpointID}&children=10`) + .expect(200); + expect(body.childNodes.length).to.eql(10); + expect(body.nextChild).to.be(null); + expect(body.childNodes[0].lifecycle.length).to.eql(1); + expect( + // for some reason the ts server doesn't think `endgame` exists even though we're using ResolverEvent + // here, so to avoid it complaining we'll just force it + (body.childNodes[0].lifecycle[0] as SafeLegacyEndpointEvent).endgame.unique_pid + ).to.eql(93932); + }); + + it('returns no values when there is no more data', async () => { + let { body }: { body: SafeResolverChildren } = await supertest + .get( + // there should only be a single child for this node + `/api/endpoint/resolver/94041/children?legacyEndpointID=${endpointID}&children=1` + ) + .expect(200); + expect(body.nextChild).to.not.be(null); + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/94041/children?legacyEndpointID=${endpointID}&afterChild=${body.nextChild}` + ) + .expect(200)); + expect(body.childNodes).be.empty(); + expect(body.nextChild).to.eql(null); + }); + + it('returns the first page of information when the cursor is invalid', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get( + `/api/endpoint/resolver/${entityID}/children?legacyEndpointID=${endpointID}&afterChild=blah` + ) + .expect(200); + expect(body.childNodes.length).to.eql(1); + expect(body.nextChild).to.be(null); + }); + + it('errors on invalid pagination values', async () => { + await supertest.get(`/api/endpoint/resolver/${entityID}/children?children=0`).expect(400); + await supertest + .get(`/api/endpoint/resolver/${entityID}/children?children=20000`) + .expect(400); + await supertest + .get(`/api/endpoint/resolver/${entityID}/children?children=-1`) + .expect(400); + }); + + it('returns empty events without a matching entity id', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/5555/children`) + .expect(200); + expect(body.nextChild).to.eql(null); + expect(body.childNodes).to.be.empty(); + }); + + it('returns empty events with an invalid endpoint id', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${entityID}/children?legacyEndpointID=foo`) + .expect(200); + expect(body.nextChild).to.eql(null); + expect(body.childNodes).to.be.empty(); + }); + }); + + describe('endpoint events', () => { + it('returns all children for the origin', async () => { + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/children?children=100`) + .expect(200); + // there are 2 levels in the children part of the tree and 3 nodes for each = + // 3 children for the origin + 3 children for each of the origin's children = 12 + expect(body.childNodes.length).to.eql(12); + // there will be 4 parents, the origin of the tree, and it's 3 children + verifyChildrenFromEntityTreeAPI(body.childNodes, tree, 4, 3); + expect(body.nextChild).to.eql(null); + }); + + it('returns a single generation of children', async () => { + // this gets a node should have 3 children which were created in succession so that the timestamps + // are ordered correctly to be retrieved in a single call + const distantChildEntityID = Array.from(tree.childrenLevels[0].values())[0].id; + const { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${distantChildEntityID}/children?children=3`) + .expect(200); + expect(body.childNodes.length).to.eql(3); + verifyChildrenFromEntityTreeAPI(body.childNodes, tree, 1, 3); + expect(body.nextChild).to.not.eql(null); + }); + + it('paginates the children', async () => { + // this gets a node should have 3 children which were created in succession so that the timestamps + // are ordered correctly to be retrieved in a single call + const distantChildEntityID = Array.from(tree.childrenLevels[0].values())[0].id; + let { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${distantChildEntityID}/children?children=1`) + .expect(200); + expect(body.childNodes.length).to.eql(1); + verifyChildrenFromEntityTreeAPI(body.childNodes, tree, 1, 1); + expect(body.nextChild).to.not.be(null); + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/${distantChildEntityID}/children?children=2&afterChild=${body.nextChild}` + ) + .expect(200)); + expect(body.childNodes.length).to.eql(2); + verifyChildrenFromEntityTreeAPI(body.childNodes, tree, 1, 2); + expect(body.nextChild).to.not.be(null); + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/${distantChildEntityID}/children?children=2&afterChild=${body.nextChild}` + ) + .expect(200)); + expect(body.childNodes.length).to.eql(0); + expect(body.nextChild).to.be(null); + }); + + it('gets all children in two queries', async () => { + // should get all the children of the origin + let { body }: { body: SafeResolverChildren } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/children?children=3`) + .expect(200); + expect(body.childNodes.length).to.eql(3); + verifyChildrenFromEntityTreeAPI(body.childNodes, tree); + expect(body.nextChild).to.not.be(null); + const firstNodes = [...body.childNodes]; + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/${tree.origin.id}/children?children=10&afterChild=${body.nextChild}` + ) + .expect(200)); + expect(body.childNodes.length).to.eql(9); + // put all the results together and we should have all the children + verifyChildrenFromEntityTreeAPI([...firstNodes, ...body.childNodes], tree, 4, 3); + expect(body.nextChild).to.be(null); + }); + }); + }); + + describe('tree api', () => { + describe('legacy events', () => { + const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; + + it('returns ancestors, events, children, and current process lifecycle', async () => { + const { body }: { body: SafeResolverTree } = await supertest + .get(`/api/endpoint/resolver/93933?legacyEndpointID=${endpointID}`) + .expect(200); + expect(body.ancestry.nextAncestor).to.equal(null); + expect(body.children.nextChild).to.equal(null); + expect(body.children.childNodes.length).to.equal(0); + expect(body.lifecycle.length).to.equal(2); + }); + }); + + describe('endpoint events', () => { + it('returns a tree', async () => { + const { body }: { body: SafeResolverTree } = await supertest + .get( + `/api/endpoint/resolver/${tree.origin.id}?children=100&ancestors=5&events=5&alerts=5` + ) + .expect(200); + + expect(body.children.nextChild).to.equal(null); + expect(body.children.childNodes.length).to.equal(12); + verifyChildrenFromEntityTreeAPI(body.children.childNodes, tree, 4, 3); + verifyLifecycleStats(body.children.childNodes, relatedEventsToGen, relatedAlerts); + + expect(body.ancestry.nextAncestor).to.equal(null); + checkAncestryFromEntityTreeAPI(body.ancestry.ancestors, tree, true); + verifyLifecycleStats(body.ancestry.ancestors, relatedEventsToGen, relatedAlerts); + + expect(body.relatedAlerts.nextAlert).to.equal(null); + compareArrays(tree.origin.relatedAlerts, body.relatedAlerts.alerts, true); + + compareArrays(tree.origin.lifecycle, body.lifecycle, true); + verifyEntityTreeStats(body.stats, relatedEventsToGen, relatedAlerts); + }); + }); + }); + }); +} From 5bc4d75256afc543aec79d93f023fc75158a56a0 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 24 Nov 2020 11:26:46 -0600 Subject: [PATCH 32/89] [deb/rpm] Move systemd service to /usr/lib/systemd/system (#83571) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../systemd/{etc => usr/lib}/systemd/system/kibana.service | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/dev/build/tasks/os_packages/service_templates/systemd/{etc => usr/lib}/systemd/system/kibana.service (100%) diff --git a/src/dev/build/tasks/os_packages/service_templates/systemd/etc/systemd/system/kibana.service b/src/dev/build/tasks/os_packages/service_templates/systemd/usr/lib/systemd/system/kibana.service similarity index 100% rename from src/dev/build/tasks/os_packages/service_templates/systemd/etc/systemd/system/kibana.service rename to src/dev/build/tasks/os_packages/service_templates/systemd/usr/lib/systemd/system/kibana.service From 38a09b99c4314d2a2aa50a9e2780caad94e5e91c Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 24 Nov 2020 18:42:02 +0100 Subject: [PATCH 33/89] Expression: Add render mode and use it for canvas interactivity (#83559) --- ...c.expressionrenderhandler._constructor_.md | 4 +- ...ressions-public.expressionrenderhandler.md | 2 +- ...ressions-public.iexpressionloaderparams.md | 1 + ...blic.iexpressionloaderparams.rendermode.md | 11 ++ ...interpreterrenderhandlers.getrendermode.md | 11 ++ ...sions-public.iinterpreterrenderhandlers.md | 1 + ...interpreterrenderhandlers.getrendermode.md | 11 ++ ...sions-server.iinterpreterrenderhandlers.md | 1 + .../common/expression_renderers/types.ts | 13 ++ src/plugins/expressions/public/loader.test.ts | 28 ++- src/plugins/expressions/public/loader.ts | 1 + src/plugins/expressions/public/public.api.md | 8 +- src/plugins/expressions/public/render.ts | 8 +- src/plugins/expressions/public/types/index.ts | 2 + src/plugins/expressions/server/server.api.md | 4 + .../functions/external/saved_lens.ts | 1 + .../renderers/__stories__/render.tsx | 1 + .../canvas/public/lib/create_handlers.ts | 3 + .../embeddable/embeddable.test.tsx | 39 ++++ .../embeddable/embeddable.tsx | 3 + .../embeddable/expression_wrapper.tsx | 4 + .../public/pie_visualization/expression.tsx | 1 + .../render_function.test.tsx | 9 + .../pie_visualization/render_function.tsx | 17 +- .../xy_visualization/expression.test.tsx | 84 +++++++++ .../public/xy_visualization/expression.tsx | 172 +++++++++--------- 26 files changed, 344 insertions(+), 96 deletions(-) create mode 100644 docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md create mode 100644 docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md create mode 100644 docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md index fb6ba7ee2621..fcccd3f6b961 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `ExpressionRenderHandler` class Signature: ```typescript -constructor(element: HTMLElement, { onRenderError }?: Partial); +constructor(element: HTMLElement, { onRenderError, renderMode }?: Partial); ``` ## Parameters @@ -17,5 +17,5 @@ constructor(element: HTMLElement, { onRenderError }?: PartialHTMLElement | | -| { onRenderError } | Partial<ExpressionRenderHandlerParams> | | +| { onRenderError, renderMode } | Partial<ExpressionRenderHandlerParams> | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md index 7f7d5792ba68..12c663273bd8 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md @@ -14,7 +14,7 @@ export declare class ExpressionRenderHandler | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(element, { onRenderError })](./kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md) | | Constructs a new instance of the ExpressionRenderHandler class | +| [(constructor)(element, { onRenderError, renderMode })](./kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md) | | Constructs a new instance of the ExpressionRenderHandler class | ## Properties diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md index 2dfc67d2af5f..54eecad0deb5 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md @@ -21,6 +21,7 @@ export interface IExpressionLoaderParams | [disableCaching](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.disablecaching.md) | boolean | | | [inspectorAdapters](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.inspectoradapters.md) | Adapters | | | [onRenderError](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.onrendererror.md) | RenderErrorHandlerFnType | | +| [renderMode](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md) | RenderMode | | | [searchContext](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.searchcontext.md) | SerializableState | | | [searchSessionId](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.searchsessionid.md) | string | | | [uiState](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.uistate.md) | unknown | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md new file mode 100644 index 000000000000..2986b81fc67c --- /dev/null +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [IExpressionLoaderParams](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md) > [renderMode](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md) + +## IExpressionLoaderParams.renderMode property + +Signature: + +```typescript +renderMode?: RenderMode; +``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md new file mode 100644 index 000000000000..8cddec1a5359 --- /dev/null +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [IInterpreterRenderHandlers](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md) > [getRenderMode](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md) + +## IInterpreterRenderHandlers.getRenderMode property + +Signature: + +```typescript +getRenderMode: () => RenderMode; +``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md index ab0273be7140..a65e02545163 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md @@ -16,6 +16,7 @@ export interface IInterpreterRenderHandlers | --- | --- | --- | | [done](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.done.md) | () => void | Done increments the number of rendering successes | | [event](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.event.md) | (event: any) => void | | +| [getRenderMode](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md) | () => RenderMode | | | [onDestroy](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.ondestroy.md) | (fn: () => void) => void | | | [reload](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.reload.md) | () => void | | | [uiState](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.uistate.md) | PersistedState | | diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md new file mode 100644 index 000000000000..16db25ab244f --- /dev/null +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-server](./kibana-plugin-plugins-expressions-server.md) > [IInterpreterRenderHandlers](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md) > [getRenderMode](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md) + +## IInterpreterRenderHandlers.getRenderMode property + +Signature: + +```typescript +getRenderMode: () => RenderMode; +``` diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md index ccf6271f712b..b1496386944f 100644 --- a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md @@ -16,6 +16,7 @@ export interface IInterpreterRenderHandlers | --- | --- | --- | | [done](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.done.md) | () => void | Done increments the number of rendering successes | | [event](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.event.md) | (event: any) => void | | +| [getRenderMode](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md) | () => RenderMode | | | [onDestroy](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.ondestroy.md) | (fn: () => void) => void | | | [reload](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.reload.md) | () => void | | | [uiState](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.uistate.md) | PersistedState | | diff --git a/src/plugins/expressions/common/expression_renderers/types.ts b/src/plugins/expressions/common/expression_renderers/types.ts index 0ea3d72e7560..dd3124c7d17e 100644 --- a/src/plugins/expressions/common/expression_renderers/types.ts +++ b/src/plugins/expressions/common/expression_renderers/types.ts @@ -61,6 +61,18 @@ export interface ExpressionRenderDefinition { export type AnyExpressionRenderDefinition = ExpressionRenderDefinition; +/** + * Mode of the expression render environment. + * This value can be set from a consumer embedding an expression renderer and is accessible + * from within the active render function as part of the handlers. + * The following modes are supported: + * * display (default): The chart is rendered in a container with the main purpose of viewing the chart (e.g. in a container like dashboard or canvas) + * * preview: The chart is rendered in very restricted space (below 100px width and height) and should only show a rough outline + * * edit: The chart is rendered within an editor and configuration elements within the chart should be displayed + * * noInteractivity: The chart is rendered in a non-interactive environment and should not provide any affordances for interaction like brushing + */ +export type RenderMode = 'noInteractivity' | 'edit' | 'preview' | 'display'; + export interface IInterpreterRenderHandlers { /** * Done increments the number of rendering successes @@ -70,5 +82,6 @@ export interface IInterpreterRenderHandlers { reload: () => void; update: (params: any) => void; event: (event: any) => void; + getRenderMode: () => RenderMode; uiState?: PersistedState; } diff --git a/src/plugins/expressions/public/loader.test.ts b/src/plugins/expressions/public/loader.test.ts index bf8b44276956..598b614a326a 100644 --- a/src/plugins/expressions/public/loader.test.ts +++ b/src/plugins/expressions/public/loader.test.ts @@ -20,17 +20,24 @@ import { first, skip, toArray } from 'rxjs/operators'; import { loader, ExpressionLoader } from './loader'; import { Observable } from 'rxjs'; -import { parseExpression, IInterpreterRenderHandlers } from '../common'; +import { + parseExpression, + IInterpreterRenderHandlers, + RenderMode, + AnyExpressionFunctionDefinition, +} from '../common'; // eslint-disable-next-line -const { __getLastExecution } = require('./services'); +const { __getLastExecution, __getLastRenderMode } = require('./services'); const element: HTMLElement = null as any; jest.mock('./services', () => { + let renderMode: RenderMode | undefined; const renderers: Record = { test: { render: (el: HTMLElement, value: unknown, handlers: IInterpreterRenderHandlers) => { + renderMode = handlers.getRenderMode(); handlers.done(); }, }, @@ -39,9 +46,18 @@ jest.mock('./services', () => { // eslint-disable-next-line const service = new (require('../common/service/expressions_services').ExpressionsService as any)(); + const testFn: AnyExpressionFunctionDefinition = { + fn: () => ({ type: 'render', as: 'test' }), + name: 'testrender', + args: {}, + help: '', + }; + service.registerFunction(testFn); + const moduleMock = { __execution: undefined, __getLastExecution: () => moduleMock.__execution, + __getLastRenderMode: () => renderMode, getRenderersRegistry: () => ({ get: (id: string) => renderers[id], }), @@ -130,6 +146,14 @@ describe('ExpressionLoader', () => { expect(response).toBe(2); }); + it('passes mode to the renderer', async () => { + const expressionLoader = new ExpressionLoader(element, 'testrender', { + renderMode: 'edit', + }); + await expressionLoader.render$.pipe(first()).toPromise(); + expect(__getLastRenderMode()).toEqual('edit'); + }); + it('cancels the previous request when the expression is updated', () => { const expressionLoader = new ExpressionLoader(element, 'var foo', {}); const execution = __getLastExecution(); diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 91c482621de3..983a344c0e1a 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -63,6 +63,7 @@ export class ExpressionLoader { this.renderHandler = new ExpressionRenderHandler(element, { onRenderError: params && params.onRenderError, + renderMode: params?.renderMode, }); this.render$ = this.renderHandler.render$; this.update$ = this.renderHandler.update$; diff --git a/src/plugins/expressions/public/public.api.md b/src/plugins/expressions/public/public.api.md index 17f8e6255f6b..2a73cd6e208d 100644 --- a/src/plugins/expressions/public/public.api.md +++ b/src/plugins/expressions/public/public.api.md @@ -530,7 +530,7 @@ export interface ExpressionRenderError extends Error { // @public (undocumented) export class ExpressionRenderHandler { // Warning: (ae-forgotten-export) The symbol "ExpressionRenderHandlerParams" needs to be exported by the entry point index.d.ts - constructor(element: HTMLElement, { onRenderError }?: Partial); + constructor(element: HTMLElement, { onRenderError, renderMode }?: Partial); // (undocumented) destroy: () => void; // (undocumented) @@ -891,6 +891,10 @@ export interface IExpressionLoaderParams { // // (undocumented) onRenderError?: RenderErrorHandlerFnType; + // Warning: (ae-forgotten-export) The symbol "RenderMode" needs to be exported by the entry point index.d.ts + // + // (undocumented) + renderMode?: RenderMode; // (undocumented) searchContext?: SerializableState_2; // (undocumented) @@ -909,6 +913,8 @@ export interface IInterpreterRenderHandlers { // (undocumented) event: (event: any) => void; // (undocumented) + getRenderMode: () => RenderMode; + // (undocumented) onDestroy: (fn: () => void) => void; // (undocumented) reload: () => void; diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index 924f8d4830f7..4390033b5be6 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -22,7 +22,7 @@ import { Observable } from 'rxjs'; import { filter } from 'rxjs/operators'; import { ExpressionRenderError, RenderErrorHandlerFnType, IExpressionLoaderParams } from './types'; import { renderErrorHandler as defaultRenderErrorHandler } from './render_error_handler'; -import { IInterpreterRenderHandlers, ExpressionAstExpression } from '../common'; +import { IInterpreterRenderHandlers, ExpressionAstExpression, RenderMode } from '../common'; import { getRenderersRegistry } from './services'; @@ -30,6 +30,7 @@ export type IExpressionRendererExtraHandlers = Record; export interface ExpressionRenderHandlerParams { onRenderError: RenderErrorHandlerFnType; + renderMode: RenderMode; } export interface ExpressionRendererEvent { @@ -58,7 +59,7 @@ export class ExpressionRenderHandler { constructor( element: HTMLElement, - { onRenderError }: Partial = {} + { onRenderError, renderMode }: Partial = {} ) { this.element = element; @@ -92,6 +93,9 @@ export class ExpressionRenderHandler { event: (data) => { this.eventsSubject.next(data); }, + getRenderMode: () => { + return renderMode || 'display'; + }, }; } diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index 4af36fea169a..5bae98569947 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -23,6 +23,7 @@ import { ExpressionValue, ExpressionsService, SerializableState, + RenderMode, } from '../../common'; /** @@ -54,6 +55,7 @@ export interface IExpressionLoaderParams { inspectorAdapters?: Adapters; onRenderError?: RenderErrorHandlerFnType; searchSessionId?: string; + renderMode?: RenderMode; } export interface ExpressionRenderError extends Error { diff --git a/src/plugins/expressions/server/server.api.md b/src/plugins/expressions/server/server.api.md index e5b499206ebd..33ff759faa3b 100644 --- a/src/plugins/expressions/server/server.api.md +++ b/src/plugins/expressions/server/server.api.md @@ -729,6 +729,10 @@ export interface IInterpreterRenderHandlers { done: () => void; // (undocumented) event: (event: any) => void; + // Warning: (ae-forgotten-export) The symbol "RenderMode" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getRenderMode: () => RenderMode; // (undocumented) onDestroy: (fn: () => void) => void; // (undocumented) diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts index 765ff5072822..380d07972ca4 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts @@ -83,6 +83,7 @@ export function savedLens(): ExpressionFunctionDefinition< title: args.title === null ? undefined : args.title, disableTriggers: true, palette: args.palette, + renderMode: 'noInteractivity', }, embeddableType: EmbeddableTypes.lens, generatedAt: Date.now(), diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx index 647c63c2c104..54702f265483 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx @@ -11,6 +11,7 @@ export const defaultHandlers: RendererHandlers = { destroy: () => action('destroy'), getElementId: () => 'element-id', getFilter: () => 'filter', + getRenderMode: () => 'display', onComplete: (fn) => undefined, onEmbeddableDestroyed: action('onEmbeddableDestroyed'), onEmbeddableInputChange: action('onEmbeddableInputChange'), diff --git a/x-pack/plugins/canvas/public/lib/create_handlers.ts b/x-pack/plugins/canvas/public/lib/create_handlers.ts index ae0956ee2128..9bc4bd5e78fd 100644 --- a/x-pack/plugins/canvas/public/lib/create_handlers.ts +++ b/x-pack/plugins/canvas/public/lib/create_handlers.ts @@ -23,6 +23,9 @@ export const createHandlers = (): RendererHandlers => ({ getFilter() { return ''; }, + getRenderMode() { + return 'display'; + }, onComplete(fn: () => void) { this.done = fn; }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index 9f9d7fef9c7b..3a3258a79c59 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -262,6 +262,45 @@ describe('embeddable', () => { expect(expressionRenderer.mock.calls[0][0].searchSessionId).toBe(input.searchSessionId); }); + it('should pass render mode to expression', async () => { + const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; + const query: Query = { language: 'kquery', query: '' }; + const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }]; + + const input = { + savedObjectId: '123', + timeRange, + query, + filters, + renderMode: 'noInteractivity', + } as LensEmbeddableInput; + + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), + }, + input + ); + await embeddable.initializeSavedVis(input); + embeddable.render(mountpoint); + + expect(expressionRenderer.mock.calls[0][0].renderMode).toEqual('noInteractivity'); + }); + it('should merge external context with query and filters of the saved object', async () => { const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; const query: Query = { language: 'kquery', query: 'external filter' }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index 8139631daa97..76276f8b4c82 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -20,6 +20,7 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { Subscription } from 'rxjs'; import { toExpression, Ast } from '@kbn/interpreter/common'; +import { RenderMode } from 'src/plugins/expressions'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -53,6 +54,7 @@ export type LensByValueInput = { export type LensByReferenceInput = SavedObjectEmbeddableInput & EmbeddableInput; export type LensEmbeddableInput = (LensByValueInput | LensByReferenceInput) & { palette?: PaletteOutput; + renderMode?: RenderMode; }; export interface LensEmbeddableOutput extends EmbeddableOutput { @@ -192,6 +194,7 @@ export class Embeddable variables={input.palette ? { theme: { palette: input.palette } } : {}} searchSessionId={this.input.searchSessionId} handleEvent={this.handleEvent} + renderMode={input.renderMode} />, domNode ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index 4a3ba971381f..d18372246b0e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -13,6 +13,7 @@ import { ReactExpressionRendererType, } from 'src/plugins/expressions/public'; import { ExecutionContextSearch } from 'src/plugins/data/public'; +import { RenderMode } from 'src/plugins/expressions'; import { getOriginalRequestErrorMessage } from '../error_helper'; export interface ExpressionWrapperProps { @@ -22,6 +23,7 @@ export interface ExpressionWrapperProps { searchContext: ExecutionContextSearch; searchSessionId?: string; handleEvent: (event: ExpressionRendererEvent) => void; + renderMode?: RenderMode; } export function ExpressionWrapper({ @@ -31,6 +33,7 @@ export function ExpressionWrapper({ variables, handleEvent, searchSessionId, + renderMode, }: ExpressionWrapperProps) { return ( @@ -57,6 +60,7 @@ export function ExpressionWrapper({ expression={expression} searchContext={searchContext} searchSessionId={searchSessionId} + renderMode={renderMode} renderError={(errorMessage, error) => (
    diff --git a/x-pack/plugins/lens/public/pie_visualization/expression.tsx b/x-pack/plugins/lens/public/pie_visualization/expression.tsx index 3b5226eaa8e1..5f18ef7c7f63 100644 --- a/x-pack/plugins/lens/public/pie_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/expression.tsx @@ -139,6 +139,7 @@ export const getPieRenderer = (dependencies: { chartsThemeService={dependencies.chartsThemeService} paletteService={dependencies.paletteService} onClickValue={onClickValue} + renderMode={handlers.getRenderMode()} /> , domNode, diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index c44179ccd8df..458b1a75c4c1 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -70,6 +70,7 @@ describe('PieVisualization component', () => { onClickValue: jest.fn(), chartsThemeService, paletteService: chartPluginMock.createPaletteRegistry(), + renderMode: 'display' as const, }; } @@ -266,6 +267,14 @@ describe('PieVisualization component', () => { `); }); + test('does not set click listener on noInteractivity render mode', () => { + const defaultArgs = getDefaultArgs(); + const component = shallow( + + ); + expect(component.find(Settings).first().prop('onElementClick')).toBeUndefined(); + }); + test('it shows emptyPlaceholder for undefined grouped data', () => { const defaultData = getDefaultArgs().data; const emptyData: LensMultiTable = { diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index 39743a355fd7..20d558fefc3d 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -20,7 +20,9 @@ import { RecursivePartial, Position, Settings, + ElementClickListener, } from '@elastic/charts'; +import { RenderMode } from 'src/plugins/expressions'; import { FormatFactory, LensFilterEvent } from '../types'; import { VisualizationContainer } from '../visualization_container'; import { CHART_NAMES, DEFAULT_PERCENT_DECIMALS } from './constants'; @@ -44,6 +46,7 @@ export function PieComponent( chartsThemeService: ChartsPluginSetup['theme']; paletteService: PaletteRegistry; onClickValue: (data: LensFilterEvent['data']) => void; + renderMode: RenderMode; } ) { const [firstTable] = Object.values(props.data.tables); @@ -228,6 +231,12 @@ export function PieComponent( ); } + + const onElementClickHandler: ElementClickListener = (args) => { + const context = getFilterContext(args[0][0] as LayerValue[], groups, firstTable); + + onClickValue(desanitizeFilterContext(context)); + }; return ( { - const context = getFilterContext(args[0][0] as LayerValue[], groups, firstTable); - - onClickValue(desanitizeFilterContext(context)); - }} + onElementClick={ + props.renderMode !== 'noInteractivity' ? onElementClickHandler : undefined + } theme={{ ...chartTheme, background: { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index a4b5d741c80f..0e2b47410c3f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -427,6 +427,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -451,6 +452,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'line' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -504,6 +506,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={undefined} @@ -541,6 +544,7 @@ describe('xy_expression', () => { args={multiLayerArgs} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -578,6 +582,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -596,6 +601,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -617,6 +623,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'area' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -638,6 +645,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar_horizontal' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -664,6 +672,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -688,6 +697,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -773,6 +783,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -791,6 +802,27 @@ describe('xy_expression', () => { }); }); + test('onBrushEnd is not set on noInteractivity mode', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(Settings).first().prop('onBrushEnd')).toBeUndefined(); + }); + test('onElementClick returns correct context data', () => { const geometry: GeometryValue = { x: 5, y: 1, accessor: 'y1', mark: null, datum: {} }; const series = { @@ -825,6 +857,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -855,6 +888,27 @@ describe('xy_expression', () => { }); }); + test('onElementClick is not triggering event on noInteractivity mode', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(Settings).first().prop('onElementClick')).toBeUndefined(); + }); + test('it renders stacked bar', () => { const { data, args } = sampleArgs(); const component = shallow( @@ -863,6 +917,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'bar_stacked' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -884,6 +939,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], seriesType: 'area_stacked' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -908,6 +964,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -941,6 +998,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -961,6 +1019,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="CEST" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -987,6 +1046,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [firstLayer] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1007,6 +1067,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [firstLayer] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1030,6 +1091,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [firstLayer, secondLayer] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1058,6 +1120,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1080,6 +1143,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1481,6 +1545,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], xScaleType: 'ordinal' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1501,6 +1566,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], yScaleType: 'sqrt' }] }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1521,6 +1587,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1544,6 +1611,7 @@ describe('xy_expression', () => { paletteService={paletteService} minInterval={50} timeZone="UTC" + renderMode="display" onClickValue={onClickValue} onSelectRange={onSelectRange} /> @@ -1563,6 +1631,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1598,6 +1667,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1631,6 +1701,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1664,6 +1735,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1697,6 +1769,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1797,6 +1870,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1871,6 +1945,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1943,6 +2018,7 @@ describe('xy_expression', () => { args={args} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1967,6 +2043,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -1990,6 +2067,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2013,6 +2091,7 @@ describe('xy_expression', () => { }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2048,6 +2127,7 @@ describe('xy_expression', () => { args={{ ...args, fittingFunction: 'Carry' }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2075,6 +2155,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2097,6 +2178,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2124,6 +2206,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} @@ -2157,6 +2240,7 @@ describe('xy_expression', () => { args={{ ...args }} formatFactory={getFormatSpy} timeZone="UTC" + renderMode="display" chartsThemeService={chartsThemeService} paletteService={paletteService} minInterval={50} diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 54ae3bb759d2..790416a6c920 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -21,6 +21,8 @@ import { StackMode, VerticalAlignment, HorizontalAlignment, + ElementClickListener, + BrushEndListener, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { @@ -31,6 +33,7 @@ import { } from 'src/plugins/expressions/public'; import { IconType } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { RenderMode } from 'src/plugins/expressions'; import { LensMultiTable, FormatFactory, @@ -81,6 +84,7 @@ type XYChartRenderProps = XYChartProps & { minInterval: number | undefined; onClickValue: (data: LensFilterEvent['data']) => void; onSelectRange: (data: LensBrushEvent['data']) => void; + renderMode: RenderMode; }; export const xyChart: ExpressionFunctionDefinition< @@ -235,6 +239,7 @@ export const getXyChartRenderer = (dependencies: { minInterval={await calculateMinInterval(config, dependencies.getIntervalByColumn)} onClickValue={onClickValue} onSelectRange={onSelectRange} + renderMode={handlers.getRenderMode()} /> , domNode, @@ -303,6 +308,7 @@ export function XYChart({ minInterval, onClickValue, onSelectRange, + renderMode, }: XYChartRenderProps) { const { legend, layers, fittingFunction, gridlinesVisibilitySettings, valueLabels } = args; const chartTheme = chartsThemeService.useChartsTheme(); @@ -415,6 +421,87 @@ export function XYChart({ const colorAssignments = getColorAssignments(args.layers, data, formatFactory); + const clickHandler: ElementClickListener = ([[geometry, series]]) => { + // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue + const xySeries = series as XYChartSeriesIdentifier; + const xyGeometry = geometry as GeometryValue; + + const layer = filteredLayers.find((l) => + xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) + ); + if (!layer) { + return; + } + + const table = data.tables[layer.layerId]; + + const points = [ + { + row: table.rows.findIndex((row) => { + if (layer.xAccessor) { + if (layersAlreadyFormatted[layer.xAccessor]) { + // stringify the value to compare with the chart value + return xAxisFormatter.convert(row[layer.xAccessor]) === xyGeometry.x; + } + return row[layer.xAccessor] === xyGeometry.x; + } + }), + column: table.columns.findIndex((col) => col.id === layer.xAccessor), + value: xyGeometry.x, + }, + ]; + + if (xySeries.seriesKeys.length > 1) { + const pointValue = xySeries.seriesKeys[0]; + + points.push({ + row: table.rows.findIndex( + (row) => layer.splitAccessor && row[layer.splitAccessor] === pointValue + ), + column: table.columns.findIndex((col) => col.id === layer.splitAccessor), + value: pointValue, + }); + } + + const xAxisFieldName = table.columns.find((el) => el.id === layer.xAccessor)?.meta?.field; + const timeFieldName = xDomain && xAxisFieldName; + + const context: LensFilterEvent['data'] = { + data: points.map((point) => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + timeFieldName, + }; + onClickValue(desanitizeFilterContext(context)); + }; + + const brushHandler: BrushEndListener = ({ x }) => { + if (!x) { + return; + } + const [min, max] = x; + if (!xAxisColumn || !isHistogramViz) { + return; + } + + const table = data.tables[filteredLayers[0].layerId]; + + const xAxisColumnIndex = table.columns.findIndex((el) => el.id === filteredLayers[0].xAccessor); + + const timeFieldName = isTimeViz ? table.columns[xAxisColumnIndex]?.meta?.field : undefined; + + const context: LensBrushEvent['data'] = { + range: [min, max], + table, + column: xAxisColumnIndex, + timeFieldName, + }; + onSelectRange(context); + }; + return ( { - if (!x) { - return; - } - const [min, max] = x; - if (!xAxisColumn || !isHistogramViz) { - return; - } - - const table = data.tables[filteredLayers[0].layerId]; - - const xAxisColumnIndex = table.columns.findIndex( - (el) => el.id === filteredLayers[0].xAccessor - ); - - const timeFieldName = isTimeViz - ? table.columns[xAxisColumnIndex]?.meta?.field - : undefined; - - const context: LensBrushEvent['data'] = { - range: [min, max], - table, - column: xAxisColumnIndex, - timeFieldName, - }; - onSelectRange(context); - }} - onElementClick={([[geometry, series]]) => { - // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue - const xySeries = series as XYChartSeriesIdentifier; - const xyGeometry = geometry as GeometryValue; - - const layer = filteredLayers.find((l) => - xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) - ); - if (!layer) { - return; - } - - const table = data.tables[layer.layerId]; - - const points = [ - { - row: table.rows.findIndex((row) => { - if (layer.xAccessor) { - if (layersAlreadyFormatted[layer.xAccessor]) { - // stringify the value to compare with the chart value - return xAxisFormatter.convert(row[layer.xAccessor]) === xyGeometry.x; - } - return row[layer.xAccessor] === xyGeometry.x; - } - }), - column: table.columns.findIndex((col) => col.id === layer.xAccessor), - value: xyGeometry.x, - }, - ]; - - if (xySeries.seriesKeys.length > 1) { - const pointValue = xySeries.seriesKeys[0]; - - points.push({ - row: table.rows.findIndex( - (row) => layer.splitAccessor && row[layer.splitAccessor] === pointValue - ), - column: table.columns.findIndex((col) => col.id === layer.splitAccessor), - value: pointValue, - }); - } - - const xAxisFieldName = table.columns.find((el) => el.id === layer.xAccessor)?.meta?.field; - const timeFieldName = xDomain && xAxisFieldName; - - const context: LensFilterEvent['data'] = { - data: points.map((point) => ({ - row: point.row, - column: point.column, - value: point.value, - table, - })), - timeFieldName, - }; - onClickValue(desanitizeFilterContext(context)); - }} + onBrushEnd={renderMode !== 'noInteractivity' ? brushHandler : undefined} + onElementClick={renderMode !== 'noInteractivity' ? clickHandler : undefined} /> Date: Tue, 24 Nov 2020 12:42:46 -0500 Subject: [PATCH 34/89] Remove expressions.legacy from README (#79681) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/canvas/README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/canvas/README.md b/x-pack/plugins/canvas/README.md index 7bd9a1994ba7..f77585b5b062 100644 --- a/x-pack/plugins/canvas/README.md +++ b/x-pack/plugins/canvas/README.md @@ -149,7 +149,7 @@ yarn start #### Adding a server-side function -> Server side functions may be deprecated in a later version of Kibana as they require using an API marked _legacy_ +> Server side functions may be deprecated in a later version of Kibana Now, let's add a function which runs on the server. @@ -206,9 +206,7 @@ And then in our setup method, register it with the Expressions plugin: ```typescript setup(core: CoreSetup, plugins: CanvasExamplePluginsSetup) { - // .register requires serverFunctions and types, so pass an empty array - // if you don't have any custom types to register - plugins.expressions.__LEGACY.register({ serverFunctions, types: [] }); + serverFunctions.forEach((f) => plugins.expressions.registerFunction(f)); } ``` From c2026dfa7aa90a031f34b884a6e4dbf25c96aeb4 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 24 Nov 2020 18:54:49 +0100 Subject: [PATCH 35/89] Unskip "Copy dashboards to space" (#84115) --- .../dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts index 03765f5aa603..9326f7e240e3 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts @@ -166,7 +166,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await spaces.delete(destinationSpaceId); }); - it.skip('Dashboards linked by a drilldown are both copied to a space', async () => { + it('Dashboards linked by a drilldown are both copied to a space', async () => { await PageObjects.copySavedObjectsToSpace.openCopyToSpaceFlyoutForObject( dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME ); From d47460d08d15c71ffcb61f2be642d44ec807d581 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 24 Nov 2020 20:56:35 +0300 Subject: [PATCH 36/89] Attempt to fix incremental build error (#84152) * make fetch compatible with CollectorFetchMethod * use Alejandros suggestion --- src/plugins/usage_collection/server/collector/collector_set.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index fe4f3536ffed..cda4ce36d4e2 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -29,7 +29,7 @@ import { import { Collector, CollectorOptions } from './collector'; import { UsageCollector, UsageCollectorOptions } from './usage_collector'; -type AnyCollector = Collector; +type AnyCollector = Collector; type AnyUsageCollector = UsageCollector; interface CollectorSetConfig { From 31a5b15250a59cd5cafcc320c88f5020bf184c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Tue, 24 Nov 2020 19:27:24 +0100 Subject: [PATCH 37/89] [APM] Use `asTransactionRate` consistently everywhere (#84213) --- .../apm/common/utils/formatters/formatters.ts | 11 ---- .../ServiceMap/Popover/ServiceStatsList.tsx | 8 +-- .../app/TraceOverview/TraceList.tsx | 13 ++--- .../TransactionDetails/Distribution/index.tsx | 53 +++++-------------- .../service_inventory/ServiceList/index.tsx | 21 +------- .../TransactionList/index.tsx | 10 +--- .../charts/transaction_charts/index.tsx | 15 +----- .../public/selectors/chart_selectors.test.ts | 2 +- .../apm/public/selectors/chart_selectors.ts | 4 +- .../translations/translations/ja-JP.json | 7 --- .../translations/translations/zh-CN.json | 7 --- 11 files changed, 29 insertions(+), 122 deletions(-) diff --git a/x-pack/plugins/apm/common/utils/formatters/formatters.ts b/x-pack/plugins/apm/common/utils/formatters/formatters.ts index 2314e915e316..50ce9db09661 100644 --- a/x-pack/plugins/apm/common/utils/formatters/formatters.ts +++ b/x-pack/plugins/apm/common/utils/formatters/formatters.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import numeral from '@elastic/numeral'; -import { i18n } from '@kbn/i18n'; import { Maybe } from '../../../typings/common'; import { NOT_AVAILABLE_LABEL } from '../../i18n'; import { isFiniteNumber } from '../is_finite_number'; @@ -17,16 +16,6 @@ export function asInteger(value: number) { return numeral(value).format('0,0'); } -export function tpmUnit(type?: string) { - return type === 'request' - ? i18n.translate('xpack.apm.formatters.requestsPerMinLabel', { - defaultMessage: 'rpm', - }) - : i18n.translate('xpack.apm.formatters.transactionsPerMinLabel', { - defaultMessage: 'tpm', - }); -} - export function asPercent( numerator: Maybe, denominator: number | undefined, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx index 8463da0824bd..adbcf897669a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { asDuration, asPercent, - tpmUnit, + asTransactionRate, } from '../../../../../common/utils/formatters'; import { ServiceNodeStats } from '../../../../../common/service_map'; @@ -55,11 +55,7 @@ export function ServiceStatsList({ defaultMessage: 'Req. per minute (avg.)', } ), - description: isNumber(transactionStats.avgRequestsPerMinute) - ? `${transactionStats.avgRequestsPerMinute.toFixed(2)} ${tpmUnit( - 'request' - )}` - : null, + description: asTransactionRate(transactionStats.avgRequestsPerMinute), }, { title: i18n.translate('xpack.apm.serviceMap.errorRatePopoverStat', { diff --git a/x-pack/plugins/apm/public/components/app/TraceOverview/TraceList.tsx b/x-pack/plugins/apm/public/components/app/TraceOverview/TraceList.tsx index 4704230d7c68..e68f8a9809bf 100644 --- a/x-pack/plugins/apm/public/components/app/TraceOverview/TraceList.tsx +++ b/x-pack/plugins/apm/public/components/app/TraceOverview/TraceList.tsx @@ -8,7 +8,10 @@ import { EuiIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import styled from 'styled-components'; -import { asMillisecondDuration } from '../../../../common/utils/formatters'; +import { + asMillisecondDuration, + asTransactionRate, +} from '../../../../common/utils/formatters'; import { fontSizes, truncate } from '../../../style/variables'; import { EmptyMessage } from '../../shared/EmptyMessage'; import { ImpactBar } from '../../shared/ImpactBar'; @@ -78,13 +81,7 @@ const traceListColumns: Array> = [ }), sortable: true, dataType: 'number', - render: (value: number) => - `${value.toLocaleString()} ${i18n.translate( - 'xpack.apm.tracesTable.tracesPerMinuteUnitLabel', - { - defaultMessage: 'tpm', - } - )}`, + render: (value: number) => asTransactionRate(value), }, { field: 'impact', diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx index e92a6c7db844..bbc99fb122fc 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx @@ -22,7 +22,7 @@ import { EuiIconTip, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import d3 from 'd3'; import { isEmpty } from 'lodash'; -import React, { useCallback } from 'react'; +import React from 'react'; import { ValuesType } from 'utility-types'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { useTheme } from '../../../../../../observability/public'; @@ -70,46 +70,29 @@ export function getFormattedBuckets( ); } -const getFormatYShort = (transactionType: string | undefined) => ( - t: number -) => { +const formatYShort = (t: number) => { return i18n.translate( 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.unitShortLabel', + { + defaultMessage: '{transCount} trans.', + values: { transCount: t }, + } + ); +}; + +const formatYLong = (t: number) => { + return i18n.translate( + 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel', { defaultMessage: - '{transCount} {transType, select, request {req.} other {trans.}}', + '{transCount, plural, =0 {transactions} one {transaction} other {transactions}}', values: { transCount: t, - transType: transactionType, }, } ); }; -const getFormatYLong = (transactionType: string | undefined) => (t: number) => { - return transactionType === 'request' - ? i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.requestTypeUnitLongLabel', - { - defaultMessage: - '{transCount, plural, =0 {request} one {request} other {requests}}', - values: { - transCount: t, - }, - } - ) - : i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel', - { - defaultMessage: - '{transCount, plural, =0 {transaction} one {transaction} other {transactions}}', - values: { - transCount: t, - }, - } - ); -}; - interface Props { distribution?: TransactionDistributionAPIResponse; urlParams: IUrlParams; @@ -129,16 +112,6 @@ export function TransactionDistribution({ }: Props) { const theme = useTheme(); - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const formatYShort = useCallback(getFormatYShort(transactionType), [ - transactionType, - ]); - - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const formatYLong = useCallback(getFormatYLong(transactionType), [ - transactionType, - ]); - // no data in response if ( (!distribution || distribution.noHits) && diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx index 547a0938bc24..a4c93f95dc53 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/ServiceList/index.tsx @@ -14,8 +14,8 @@ import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { ServiceHealthStatus } from '../../../../../common/service_health_status'; import { asPercent, - asDecimal, asMillisecondDuration, + asTransactionRate, } from '../../../../../common/utils/formatters'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { fontSizes, px, truncate, unit } from '../../../../style/variables'; @@ -35,16 +35,6 @@ interface Props { } type ServiceListItem = ValuesType; -function formatNumber(value: number) { - if (value === 0) { - return '0'; - } else if (value <= 0.1) { - return '< 0.1'; - } else { - return asDecimal(value); - } -} - function formatString(value?: string | null) { return value || NOT_AVAILABLE_LABEL; } @@ -154,14 +144,7 @@ export const SERVICE_COLUMNS: Array> = [ ), align: 'left', diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx index ece923631a2f..9774538b2a7a 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/TransactionList/index.tsx @@ -10,8 +10,8 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { - asDecimal, asMillisecondDuration, + asTransactionRate, } from '../../../../../common/utils/formatters'; import { fontFamilyCode, truncate } from '../../../../style/variables'; import { ImpactBar } from '../../../shared/ImpactBar'; @@ -103,13 +103,7 @@ export function TransactionList({ items, isLoading }: Props) { ), sortable: true, dataType: 'number', - render: (value: number) => - `${asDecimal(value)} ${i18n.translate( - 'xpack.apm.transactionsTable.transactionsPerMinuteUnitLabel', - { - defaultMessage: 'tpm', - } - )}`, + render: (value: number) => asTransactionRate(value), }, { field: 'impact', diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index 61d834abda79..3af081c11c9b 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -14,20 +14,17 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, TRANSACTION_ROUTE_CHANGE, } from '../../../../../common/transaction_types'; -import { asDecimal, tpmUnit } from '../../../../../common/utils/formatters'; -import { Coordinate } from '../../../../../typings/timeseries'; +import { asTransactionRate } from '../../../../../common/utils/formatters'; import { ChartsSyncContextProvider } from '../../../../context/charts_sync_context'; import { LicenseContext } from '../../../../context/LicenseContext'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { ITransactionChartData } from '../../../../selectors/chart_selectors'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; import { TimeseriesChart } from '../timeseries_chart'; import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; @@ -46,14 +43,6 @@ export function TransactionCharts({ urlParams, fetchStatus, }: TransactionChartProps) { - const getTPMFormatter = (t: number) => { - return `${asDecimal(t)} ${tpmUnit(urlParams.transactionType)}`; - }; - - const getTPMTooltipFormatter = (y: Coordinate['y']) => { - return isValidCoordinateValue(y) ? getTPMFormatter(y) : NOT_AVAILABLE_LABEL; - }; - const { transactionType } = urlParams; const { responseTimeSeries, tpmSeries } = charts; @@ -104,7 +93,7 @@ export function TransactionCharts({ fetchStatus={fetchStatus} id="requestPerMinutes" timeseries={tpmSeries || []} - yLabelFormat={getTPMTooltipFormatter} + yLabelFormat={asTransactionRate} /> diff --git a/x-pack/plugins/apm/public/selectors/chart_selectors.test.ts b/x-pack/plugins/apm/public/selectors/chart_selectors.test.ts index 4269ec0e6c0f..a17faebc9aef 100644 --- a/x-pack/plugins/apm/public/selectors/chart_selectors.test.ts +++ b/x-pack/plugins/apm/public/selectors/chart_selectors.test.ts @@ -144,7 +144,7 @@ describe('chart selectors', () => { { color: errorColor, data: [{ x: 0, y: 0 }], - legendValue: '0.0 tpm', + legendValue: '0 tpm', title: 'HTTP 5xx', type: 'linemark', }, diff --git a/x-pack/plugins/apm/public/selectors/chart_selectors.ts b/x-pack/plugins/apm/public/selectors/chart_selectors.ts index 8330df07c21e..663fbc902810 100644 --- a/x-pack/plugins/apm/public/selectors/chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/chart_selectors.ts @@ -20,7 +20,7 @@ import { import { IUrlParams } from '../context/UrlParamsContext/types'; import { getEmptySeries } from '../components/shared/charts/helper/get_empty_series'; import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; -import { asDecimal, asDuration, tpmUnit } from '../../common/utils/formatters'; +import { asDuration, asTransactionRate } from '../../common/utils/formatters'; export interface ITpmBucket { title: string; @@ -171,7 +171,7 @@ export function getTpmSeries( return { title: bucket.key, data: bucket.dataPoints, - legendValue: `${asDecimal(bucket.avg)} ${tpmUnit(transactionType || '')}`, + legendValue: asTransactionRate(bucket.avg), type: 'linemark', color: getColor(bucket.key), }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9a28e0e53bef..ed514eda000a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4874,9 +4874,7 @@ "xpack.apm.formatters.microsTimeUnitLabel": "マイクロ秒", "xpack.apm.formatters.millisTimeUnitLabel": "ミリ秒", "xpack.apm.formatters.minutesTimeUnitLabel": "最低", - "xpack.apm.formatters.requestsPerMinLabel": "1分あたりリクエスト数", "xpack.apm.formatters.secondsTimeUnitLabel": "秒", - "xpack.apm.formatters.transactionsPerMinLabel": "1分あたりトランザクション数", "xpack.apm.header.badge.readOnly.text": "読み込み専用", "xpack.apm.header.badge.readOnly.tooltip": "を保存できませんでした", "xpack.apm.helpMenu.upgradeAssistantLink": "アップグレードアシスタント", @@ -5052,7 +5050,6 @@ "xpack.apm.servicesTable.notFoundLabel": "サービスが見つかりません", "xpack.apm.servicesTable.transactionErrorRate": "エラー率%", "xpack.apm.servicesTable.transactionsPerMinuteColumnLabel": "1 分あたりのトランザクション", - "xpack.apm.servicesTable.transactionsPerMinuteUnitLabel": "1分あたりトランザクション数", "xpack.apm.servicesTable.UpgradeAssistantLink": "Kibana アップグレードアシスタントで詳細をご覧ください", "xpack.apm.settings.agentConfig": "エージェントの編集", "xpack.apm.settings.anomaly_detection.legacy_jobs.body": "以前の統合のレガシー機械学習ジョブが見つかりました。これは、APMアプリでは使用されていません。", @@ -5155,7 +5152,6 @@ "xpack.apm.tracesTable.notFoundLabel": "このクエリのトレースが見つかりません", "xpack.apm.tracesTable.originatingServiceColumnLabel": "発生元サービス", "xpack.apm.tracesTable.tracesPerMinuteColumnLabel": "1 分あたりのトレース", - "xpack.apm.tracesTable.tracesPerMinuteUnitLabel": "1分あたりトランザクション数", "xpack.apm.transactionActionMenu.actionsButtonLabel": "アクション", "xpack.apm.transactionActionMenu.container.subtitle": "このコンテナーのログとインデックスを表示し、さらに詳細を確認できます。", "xpack.apm.transactionActionMenu.container.title": "コンテナーの詳細", @@ -5206,9 +5202,7 @@ "xpack.apm.transactionDetails.traceNotFound": "選択されたトレースが見つかりません", "xpack.apm.transactionDetails.traceSampleTitle": "トレースのサンプル", "xpack.apm.transactionDetails.transactionLabel": "トランザクション", - "xpack.apm.transactionDetails.transactionsDurationDistributionChart.requestTypeUnitLongLabel": "{transCount, plural, =0 {# request} 1 {# 件のリクエスト} other {# 件のリクエスト}}", "xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel": "{transCount, plural, =0 {# transaction} 1 {# 件のトランザクション} other {# 件のトランザクション}}", - "xpack.apm.transactionDetails.transactionsDurationDistributionChart.unitShortLabel": "{transCount} {transType, select, request {件のリクエスト} other {件のトランザクション}}", "xpack.apm.transactionDetails.transactionsDurationDistributionChartTitle": "トラザクション時間の分布", "xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingDescription": "各バケットはサンプルトランザクションを示します。利用可能なサンプルがない場合、恐らくエージェントの構成で設定されたサンプリング制限が原因です。", "xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingLabel": "サンプリング", @@ -5241,7 +5235,6 @@ "xpack.apm.transactionsTable.nameColumnLabel": "名前", "xpack.apm.transactionsTable.notFoundLabel": "トランザクションが見つかりませんでした。", "xpack.apm.transactionsTable.transactionsPerMinuteColumnLabel": "1 分あたりのトランザクション", - "xpack.apm.transactionsTable.transactionsPerMinuteUnitLabel": "1分あたりトランザクション数", "xpack.apm.tutorial.apmServer.title": "APM Server", "xpack.apm.tutorial.elasticCloud.textPre": "APM Server を有効にするには、[the Elastic Cloud console](https://cloud.elastic.co/deployments?q={cloudId}) に移動し、展開設定で APM を有効にします。有効になったら、このページを更新してください。", "xpack.apm.tutorial.elasticCloudInstructions.title": "APM エージェント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 66a00c30bd3b..a500b63fbf86 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4876,9 +4876,7 @@ "xpack.apm.formatters.microsTimeUnitLabel": "μs", "xpack.apm.formatters.millisTimeUnitLabel": "ms", "xpack.apm.formatters.minutesTimeUnitLabel": "分钟", - "xpack.apm.formatters.requestsPerMinLabel": "rpm", "xpack.apm.formatters.secondsTimeUnitLabel": "s", - "xpack.apm.formatters.transactionsPerMinLabel": "tpm", "xpack.apm.header.badge.readOnly.text": "只读", "xpack.apm.header.badge.readOnly.tooltip": "无法保存", "xpack.apm.helpMenu.upgradeAssistantLink": "升级助手", @@ -5056,7 +5054,6 @@ "xpack.apm.servicesTable.notFoundLabel": "未找到任何服务", "xpack.apm.servicesTable.transactionErrorRate": "错误率 %", "xpack.apm.servicesTable.transactionsPerMinuteColumnLabel": "每分钟事务数", - "xpack.apm.servicesTable.transactionsPerMinuteUnitLabel": "tpm", "xpack.apm.servicesTable.UpgradeAssistantLink": "通过访问 Kibana 升级助手来了解详情", "xpack.apm.settings.agentConfig": "代理配置", "xpack.apm.settings.anomaly_detection.legacy_jobs.body": "我们在以前的集成中发现 APM 应用中不再使用的旧版 Machine Learning 作业", @@ -5159,7 +5156,6 @@ "xpack.apm.tracesTable.notFoundLabel": "未找到与此查询的任何追溯信息", "xpack.apm.tracesTable.originatingServiceColumnLabel": "发起服务", "xpack.apm.tracesTable.tracesPerMinuteColumnLabel": "每分钟追溯次数", - "xpack.apm.tracesTable.tracesPerMinuteUnitLabel": "tpm", "xpack.apm.transactionActionMenu.actionsButtonLabel": "操作", "xpack.apm.transactionActionMenu.container.subtitle": "查看此容器的日志和指标以获取进一步详情。", "xpack.apm.transactionActionMenu.container.title": "容器详情", @@ -5210,9 +5206,7 @@ "xpack.apm.transactionDetails.traceNotFound": "找不到所选跟踪", "xpack.apm.transactionDetails.traceSampleTitle": "跟踪样例", "xpack.apm.transactionDetails.transactionLabel": "事务", - "xpack.apm.transactionDetails.transactionsDurationDistributionChart.requestTypeUnitLongLabel": "{transCount, plural, =0 {# 个请求} one {# 个请求} other {# 个请求}}", "xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel": "{transCount, plural, =0 {# 个事务} one {# 个事务} other {# 个事务}}", - "xpack.apm.transactionDetails.transactionsDurationDistributionChart.unitShortLabel": "{transCount} 个{transType, select, request {请求} other {事务}}", "xpack.apm.transactionDetails.transactionsDurationDistributionChartTitle": "事务持续时间分布", "xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingDescription": "每个存储桶将显示一个样例事务。如果没有可用的样例,很可能是在代理配置设置了采样限制。", "xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingLabel": "采样", @@ -5245,7 +5239,6 @@ "xpack.apm.transactionsTable.nameColumnLabel": "名称", "xpack.apm.transactionsTable.notFoundLabel": "未找到任何事务。", "xpack.apm.transactionsTable.transactionsPerMinuteColumnLabel": "每分钟事务数", - "xpack.apm.transactionsTable.transactionsPerMinuteUnitLabel": "tpm", "xpack.apm.tutorial.apmServer.title": "APM Server", "xpack.apm.tutorial.elasticCloud.textPre": "要启用 APM Server,请前往 [Elastic Cloud 控制台](https://cloud.elastic.co/deployments?q={cloudId}) 并在部署设置中启用 APM。启用后,请刷新此页面。", "xpack.apm.tutorial.elasticCloudInstructions.title": "APM 代理", From 2634009a5b33ca1d6800f66fce0b6cb727f92ec0 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Tue, 24 Nov 2020 20:32:35 +0100 Subject: [PATCH 38/89] [code coverage] collect for oss integration tests (#83907) * [code coverage] collect for oss integration tests * do not run snapshot test modified with coverage * skip failures * remove debug msg * update file names * Update packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts Co-authored-by: Spencer Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Spencer --- .../src/integration_tests/basic_optimization.test.ts | 4 ++++ .../shell_scripts/fix_html_reports_parallel.sh | 4 ++-- test/scripts/jenkins_unit.sh | 12 ++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts index 46660f0dd958..16baaddcb84b 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -233,6 +233,10 @@ it('uses cache on second run and exist cleanly', async () => { }); it('prepares assets for distribution', async () => { + if (process.env.CODE_COVERAGE) { + // test fails when testing coverage because source includes instrumentation, so skip it + return; + } const config = OptimizerConfig.create({ repoRoot: MOCK_REPO_DIR, pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins'), Path.resolve(MOCK_REPO_DIR, 'x-pack')], diff --git a/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh b/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh index 098737eb2f80..01003b6dc880 100644 --- a/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh +++ b/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh @@ -8,8 +8,8 @@ PWD=$(pwd) du -sh $COMBINED_EXRACT_DIR echo "### Jest: replacing path in json files" -for i in coverage-final xpack-coverage-final; do - sed -i "s|/dev/shm/workspace/kibana|${PWD}|g" $COMBINED_EXRACT_DIR/jest/${i}.json & +for i in oss oss-integration xpack; do + sed -i "s|/dev/shm/workspace/kibana|${PWD}|g" $COMBINED_EXRACT_DIR/jest/${i}-coverage-final.json & done wait diff --git a/test/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index a9751003e842..1f6a3d440734 100755 --- a/test/scripts/jenkins_unit.sh +++ b/test/scripts/jenkins_unit.sh @@ -2,11 +2,23 @@ source test/scripts/jenkins_test_setup.sh +rename_coverage_file() { + test -f target/kibana-coverage/jest/coverage-final.json \ + && mv target/kibana-coverage/jest/coverage-final.json \ + target/kibana-coverage/jest/$1-coverage-final.json +} + if [[ -z "$CODE_COVERAGE" ]] ; then "$(FORCE_COLOR=0 yarn bin)/grunt" jenkins:unit --dev; else echo " -> Running jest tests with coverage" node scripts/jest --ci --verbose --coverage + rename_coverage_file "oss" + echo "" + echo "" + echo " -> Running jest integration tests with coverage" + node --max-old-space-size=8192 scripts/jest_integration --ci --verbose --coverage || true; + rename_coverage_file "oss-integration" echo "" echo "" echo " -> Running mocha tests with coverage" From e8a4b7e7dd773123618c2bd0d4dde2ace7f67121 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 24 Nov 2020 11:33:34 -0800 Subject: [PATCH 39/89] [@kbn/utils] Clean target before build (#84253) Signed-off-by: Tyler Smalley --- packages/kbn-utils/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-utils/package.json b/packages/kbn-utils/package.json index a07be96f0d4d..0859faa7ed0a 100644 --- a/packages/kbn-utils/package.json +++ b/packages/kbn-utils/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "private": true, "scripts": { - "build": "../../node_modules/.bin/tsc", + "build": "rm -rf target && ../../node_modules/.bin/tsc", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" }, From 3612e3f98d579c6f5075eb552151890640d9f154 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Tue, 24 Nov 2020 14:36:57 -0500 Subject: [PATCH 40/89] [Maps] fix code-owners (#84265) --- .github/CODEOWNERS | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 834662044988..a536d1b54551 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -142,9 +142,8 @@ #CC# /src/plugins/maps_oss/ @elastic/kibana-gis #CC# /x-pack/plugins/file_upload @elastic/kibana-gis #CC# /x-pack/plugins/maps_legacy_licensing @elastic/kibana-gis -#CC# /src/plugins/home/server/tutorials @elastic/kibana-gis -#CC# /src/plugins/tile_map/ @elastic/kibana-gis -#CC# /src/plugins/region_map/ @elastic/kibana-gis +/src/plugins/tile_map/ @elastic/kibana-gis +/src/plugins/region_map/ @elastic/kibana-gis # Operations /src/dev/ @elastic/kibana-operations From cc35065f5ab027684444f3768f92d5a335e18cfd Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 24 Nov 2020 21:22:58 +0100 Subject: [PATCH 41/89] Update example docs with correct version of Boom (#84271) --- .../developer/plugin/migrating-legacy-plugins-examples.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc index abf51bb3378b..469f7a4f3adb 100644 --- a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc +++ b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc @@ -242,7 +242,7 @@ migration is complete: ---- import { schema } from '@kbn/config-schema'; import { CoreSetup } from 'kibana/server'; -import Boom from 'boom'; +import Boom from '@hapi/boom'; export class DemoPlugin { public setup(core: CoreSetup) { From 13808e019e6ebcf62b392824885b743371120bb1 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Tue, 24 Nov 2020 12:28:15 -0800 Subject: [PATCH 42/89] Deprecate `kibana.index` setting (#83988) * Deprecating `kibana.index` setting * Using ela.st service so this can be changed to the blog in the future * Adding unit tests * Revising deprecation log message * Changing the deprecation log message to be more consistent with others * Updating kibana.index docs also * Using rename deprecation as the "standard" for the deprecation messages * /s/'/` --- docs/setup/settings.asciidoc | 6 ++- src/core/server/kibana_config.test.ts | 66 +++++++++++++++++++++++++++ src/core/server/kibana_config.ts | 14 ++++++ 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/core/server/kibana_config.test.ts diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index efc7a1b93093..c22d4466ee09 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -214,10 +214,12 @@ Please use the `defaultRoute` advanced setting instead. The default application to load. *Default: `"home"`* |[[kibana-index]] `kibana.index:` - | {kib} uses an index in {es} to store saved searches, visualizations, and + | *deprecated* This setting is deprecated and will be removed in 8.0. Multitenancy by changing +`kibana.index` will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy[8.0 Breaking Changes] +for more details. {kib} uses an index in {es} to store saved searches, visualizations, and dashboards. {kib} creates a new index if the index doesn’t already exist. If you configure a custom index, the name must be lowercase, and conform to the -{es} {ref}/indices-create-index.html[index name limitations]. +{es} {ref}/indices-create-index.html[index name limitations]. *Default: `".kibana"`* | `kibana.autocompleteTimeout:` {ess-icon} diff --git a/src/core/server/kibana_config.test.ts b/src/core/server/kibana_config.test.ts new file mode 100644 index 000000000000..804c02ae99e4 --- /dev/null +++ b/src/core/server/kibana_config.test.ts @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { config } from './kibana_config'; +import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; + +const CONFIG_PATH = 'kibana'; + +const applyKibanaDeprecations = (settings: Record = {}) => { + const deprecations = config.deprecations!(configDeprecationFactory); + const deprecationMessages: string[] = []; + const _config: any = {}; + _config[CONFIG_PATH] = settings; + const migrated = applyDeprecations( + _config, + deprecations.map((deprecation) => ({ + deprecation, + path: CONFIG_PATH, + })), + (msg) => deprecationMessages.push(msg) + ); + return { + messages: deprecationMessages, + migrated, + }; +}; + +it('set correct defaults ', () => { + const configValue = config.schema.validate({}); + expect(configValue).toMatchInlineSnapshot(` + Object { + "autocompleteTerminateAfter": "PT1M40S", + "autocompleteTimeout": "PT1S", + "enabled": true, + "index": ".kibana", + } + `); +}); + +describe('deprecations', () => { + ['.foo', '.kibana'].forEach((index) => { + it('logs a warning if index is set', () => { + const { messages } = applyKibanaDeprecations({ index }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"kibana.index\\" is deprecated. Multitenancy by changing \\"kibana.index\\" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details", + ] + `); + }); + }); +}); diff --git a/src/core/server/kibana_config.ts b/src/core/server/kibana_config.ts index 17f77a6e9328..ae6897b6a6ad 100644 --- a/src/core/server/kibana_config.ts +++ b/src/core/server/kibana_config.ts @@ -18,9 +18,22 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; +import { ConfigDeprecationProvider } from '@kbn/config'; export type KibanaConfigType = TypeOf; +const deprecations: ConfigDeprecationProvider = () => [ + (settings, fromPath, log) => { + const kibana = settings[fromPath]; + if (kibana?.index) { + log( + `"kibana.index" is deprecated. Multitenancy by changing "kibana.index" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details` + ); + } + return settings; + }, +]; + export const config = { path: 'kibana', schema: schema.object({ @@ -29,4 +42,5 @@ export const config = { autocompleteTerminateAfter: schema.duration({ defaultValue: 100000 }), autocompleteTimeout: schema.duration({ defaultValue: 1000 }), }), + deprecations, }; From 115916956d875bb56e9b57961b0bbb4cf090e3e9 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 24 Nov 2020 22:15:35 +0100 Subject: [PATCH 43/89] Use correct version of Podium (#84270) --- package.json | 1 - .../src/legacy_logging_server.ts | 2 +- yarn.lock | 17 ----------------- 3 files changed, 1 insertion(+), 19 deletions(-) diff --git a/package.json b/package.json index af80102641db..571dc7302f92 100644 --- a/package.json +++ b/package.json @@ -261,7 +261,6 @@ "pdfmake": "^0.1.65", "pegjs": "0.10.0", "pngjs": "^3.4.0", - "podium": "^3.1.2", "prop-types": "^15.7.2", "proper-lockfile": "^3.2.0", "proxy-from-env": "1.0.0", diff --git a/packages/kbn-legacy-logging/src/legacy_logging_server.ts b/packages/kbn-legacy-logging/src/legacy_logging_server.ts index 45e4bda0b007..1b13eda44fff 100644 --- a/packages/kbn-legacy-logging/src/legacy_logging_server.ts +++ b/packages/kbn-legacy-logging/src/legacy_logging_server.ts @@ -18,7 +18,7 @@ */ import { ServerExtType, Server } from '@hapi/hapi'; -import Podium from 'podium'; +import Podium from '@hapi/podium'; import { setupLogging } from './setup_logging'; import { attachMetaData } from './metadata'; import { legacyLoggingConfigSchema } from './schema'; diff --git a/yarn.lock b/yarn.lock index 8d47d3e84378..dc171a44dca1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17975,15 +17975,6 @@ joi@13.x.x, joi@^13.5.2: isemail "3.x.x" topo "3.x.x" -joi@14.x.x: - version "14.3.1" - resolved "https://registry.yarnpkg.com/joi/-/joi-14.3.1.tgz#164a262ec0b855466e0c35eea2a885ae8b6c703c" - integrity sha512-LQDdM+pkOrpAn4Lp+neNIFV3axv1Vna3j38bisbQhETPMANYRbFJFUyOZcOClYvM/hppMhGWuKSFEK9vjrB+bQ== - dependencies: - hoek "6.x.x" - isemail "3.x.x" - topo "3.x.x" - joi@^17.1.1: version "17.2.1" resolved "https://registry.yarnpkg.com/joi/-/joi-17.2.1.tgz#e5140fdf07e8fecf9bc977c2832d1bdb1e3f2a0a" @@ -22237,14 +22228,6 @@ pnp-webpack-plugin@1.6.4: dependencies: ts-pnp "^1.1.6" -podium@^3.1.2: - version "3.2.0" - resolved "https://registry.yarnpkg.com/podium/-/podium-3.2.0.tgz#2a7c579ddd5408f412d014c9ffac080c41d83477" - integrity sha512-rbwvxwVkI6gRRlxZQ1zUeafrpGxZ7QPHIheinehAvGATvGIPfWRkaTeWedc5P4YjXJXEV8ZbBxPtglNylF9hjw== - dependencies: - hoek "6.x.x" - joi "14.x.x" - polished@^1.9.2: version "1.9.2" resolved "https://registry.yarnpkg.com/polished/-/polished-1.9.2.tgz#d705cac66f3a3ed1bd38aad863e2c1e269baf6b6" From 9ee1ec7f30b87c6c836fd378c97e0b265f400259 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 24 Nov 2020 21:33:47 +0000 Subject: [PATCH 44/89] chore(NA): rebalance x-pack cigroups (#84099) * chore(NA): rebalance cigroup1 into cigroup5 * chore(NA): get list api integration into cigropup1 again * chore(NA): get apm integration basic into cigropup1 again * chore(NA): move back apm_api_integration trial tests into ciGroup1 * chore(NA): move exception operators data types into ciGroup1 again * chore(NA): move detection engine api security and spaces back into ciGroup1 * chore(NA): add a new xpack cigroup11 * chore(NA): correctly create 11 xpack ci groups * chore(NA): try to balance ciGroup2 and 8 * chore(NA): reset number of xpack parallel worker builds to 10 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .ci/es-snapshots/Jenkinsfile_verify_es | 1 + .ci/jobs.yml | 1 + test/scripts/jenkins_xpack_build_kibana.sh | 3 ++- vars/kibanaCoverage.groovy | 1 + vars/tasks.groovy | 2 +- x-pack/test/case_api_integration/basic/tests/index.ts | 2 +- .../tests/exception_operators_data_types/index.ts | 2 +- .../security_and_spaces/tests/index.ts | 2 +- x-pack/test/functional/apps/discover/index.ts | 2 +- 9 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.ci/es-snapshots/Jenkinsfile_verify_es b/.ci/es-snapshots/Jenkinsfile_verify_es index a6fe980242af..3c38d6279a03 100644 --- a/.ci/es-snapshots/Jenkinsfile_verify_es +++ b/.ci/es-snapshots/Jenkinsfile_verify_es @@ -55,6 +55,7 @@ kibanaPipeline(timeoutMinutes: 150) { 'xpack-ciGroup8': kibanaPipeline.xpackCiGroupProcess(8), 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), + 'xpack-ciGroup11': kibanaPipeline.xpackCiGroupProcess(11), ]), ]) } diff --git a/.ci/jobs.yml b/.ci/jobs.yml index 3add92aadd25..d4ec8a3d5a69 100644 --- a/.ci/jobs.yml +++ b/.ci/jobs.yml @@ -31,6 +31,7 @@ JOB: - x-pack-ciGroup8 - x-pack-ciGroup9 - x-pack-ciGroup10 + - x-pack-ciGroup11 - x-pack-accessibility - x-pack-visualRegression diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index 2452e2f5b8c5..8bb6effbec89 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -22,7 +22,8 @@ node scripts/functional_tests --assert-none-excluded \ --include-tag ciGroup7 \ --include-tag ciGroup8 \ --include-tag ciGroup9 \ - --include-tag ciGroup10 + --include-tag ciGroup10 \ + --include-tag ciGroup11 # Do not build kibana for code coverage run if [[ -z "$CODE_COVERAGE" ]] ; then diff --git a/vars/kibanaCoverage.groovy b/vars/kibanaCoverage.groovy index e75ed8fef987..c43be9fb17ee 100644 --- a/vars/kibanaCoverage.groovy +++ b/vars/kibanaCoverage.groovy @@ -249,6 +249,7 @@ def xpackProks() { 'xpack-ciGroup8' : kibanaPipeline.xpackCiGroupProcess(8), 'xpack-ciGroup9' : kibanaPipeline.xpackCiGroupProcess(9), 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), + 'xpack-ciGroup11': kibanaPipeline.xpackCiGroupProcess(11), ] } diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 5a8161ebd360..b6bcc0d93f9c 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -94,7 +94,7 @@ def functionalXpack(Map params = [:]) { kibanaPipeline.buildXpack(10) if (config.ciGroups) { - def ciGroups = 1..10 + def ciGroups = 1..11 tasks(ciGroups.collect { kibanaPipeline.xpackCiGroupProcess(it) }) } diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts index 2f7af95e264f..56b473af61e6 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -10,7 +10,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; export default ({ loadTestFile }: FtrProviderContext): void => { describe('case api basic', function () { // Fastest ciGroup for the moment. - this.tags('ciGroup2'); + this.tags('ciGroup5'); loadTestFile(require.resolve('./cases/comments/delete_comment')); loadTestFile(require.resolve('./cases/comments/find_comments')); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts index d2aca34e2739..0fbb97d28442 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts @@ -9,7 +9,7 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default ({ loadTestFile }: FtrProviderContext): void => { describe('Detection exceptions data types and operators', function () { - this.tags('ciGroup1'); + this.tags('ciGroup11'); loadTestFile(require.resolve('./date')); loadTestFile(require.resolve('./double')); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index 97d5b079fd20..a2422b9e3bf4 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -9,7 +9,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default ({ loadTestFile }: FtrProviderContext): void => { describe('detection engine api security and spaces enabled', function () { - this.tags('ciGroup1'); + this.tags('ciGroup11'); loadTestFile(require.resolve('./add_actions')); loadTestFile(require.resolve('./add_prepackaged_rules')); diff --git a/x-pack/test/functional/apps/discover/index.ts b/x-pack/test/functional/apps/discover/index.ts index fc91a72c3950..13426da504bd 100644 --- a/x-pack/test/functional/apps/discover/index.ts +++ b/x-pack/test/functional/apps/discover/index.ts @@ -7,7 +7,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('discover', function () { - this.tags('ciGroup8'); + this.tags('ciGroup1'); loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./preserve_url')); From dc15aa8ea21a9bf88dbfa3afc77afc81711788b7 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 24 Nov 2020 23:26:08 +0100 Subject: [PATCH 45/89] [ML] Fix swim lane for top influencers (#84258) * [ML] fix swim lane with page size for top influencers, fix swim lane sorting * [ML] fix typo --- .../application/explorer/swimlane_container.tsx | 11 ++++++++++- .../application/routing/routes/explorer.tsx | 17 +++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index 9c7d0f6fe78e..b166d90f040a 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -184,6 +184,8 @@ export const SwimlaneContainer: FC = ({ return []; } + const sortedLaneValues = swimlaneData.laneLabels; + return swimlaneData.points .map((v) => { const formatted = { ...v, time: v.time * 1000 }; @@ -195,8 +197,15 @@ export const SwimlaneContainer: FC = ({ } return formatted; }) + .sort((a, b) => { + let aIndex = sortedLaneValues.indexOf(a.laneLabel); + let bIndex = sortedLaneValues.indexOf(b.laneLabel); + aIndex = aIndex > -1 ? aIndex : sortedLaneValues.length; + bIndex = bIndex > -1 ? bIndex : sortedLaneValues.length; + return aIndex - bIndex; + }) .filter((v) => v.value > 0); - }, [swimlaneData?.points, filterActive, swimlaneType]); + }, [swimlaneData?.points, filterActive, swimlaneType, swimlaneData?.laneLabels]); const showSwimlane = swimlaneData?.laneLabels?.length > 0 && swimLanePoints.length > 0; diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 83f876bcf7b5..f8b4de6903ad 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -231,15 +231,24 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim : undefined; useEffect(() => { + /** + * For the "View by" swim lane the limit is the cardinality of the influencer values, + * which is known after the initial fetch. + * When looking up for top influencers for selected range in Overall swim lane + * the result is filtered by top influencers values, hence there is no need to set the limit. + */ + const swimlaneLimit = + isViewBySwimLaneData(explorerState?.viewBySwimlaneData) && !selectedCells?.showTopFieldValues + ? explorerState?.viewBySwimlaneData.cardinality + : undefined; + if (explorerState && explorerState.swimlaneContainerWidth > 0) { loadExplorerData({ ...loadExplorerDataConfig, - swimlaneLimit: isViewBySwimLaneData(explorerState?.viewBySwimlaneData) - ? explorerState?.viewBySwimlaneData.cardinality - : undefined, + swimlaneLimit, }); } - }, [JSON.stringify(loadExplorerDataConfig)]); + }, [JSON.stringify(loadExplorerDataConfig), selectedCells?.showTopFieldValues]); if (explorerState === undefined || refresh === undefined || showCharts === undefined) { return null; From f80da6cc39ca833bf1b7cdf05de1ee3a03679e32 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Tue, 24 Nov 2020 15:38:12 -0700 Subject: [PATCH 46/89] [data.search] Simplify poll logic and improve types (#82545) * [Search] Add request context and asScoped pattern * Update docs * Unify interface for getting search client * Update examples/search_examples/server/my_strategy.ts Co-authored-by: Anton Dosov * Review feedback * Fix checks * Fix CI * Fix security search * Fix test * Fix test for reals * Fix types * [data.search] Refactor search polling and improve types * Fix & update tests & types * eql totals * doc * Revert "eql totals" This reverts commit 01e8a0684792c7255b4ccaad0fa09fe5e3c9c57c. * lint * response type * shim inside strategies * shim for security * fix eql params Co-authored-by: Anton Dosov Co-authored-by: Liza K Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...data-public.painlesserror._constructor_.md | 3 +- ...lugin-plugins-data-public.painlesserror.md | 2 +- ...lic.searchinterceptor.handlesearcherror.md | 3 +- ...n-plugins-data-public.searchinterceptor.md | 2 +- ...gins-data-server.getdefaultsearchparams.md | 12 +- ...gin-plugins-data-server.getshardtimeout.md | 12 +- ...ins-data-server.iesrawsearchresponse.id.md | 11 -- ...-server.iesrawsearchresponse.is_partial.md | 11 -- ...-server.iesrawsearchresponse.is_running.md | 11 -- ...lugins-data-server.iesrawsearchresponse.md | 20 --- .../kibana-plugin-plugins-data-server.md | 2 +- ...ibana-plugin-plugins-data-server.search.md | 18 --- ...plugins-data-server.searchusageobserver.md | 31 ++++ .../search/es_search/es_search_rxjs_utils.ts | 55 ------- .../search/es_search/get_total_loaded.test.ts | 36 ----- .../data/common/search/es_search/index.ts | 5 - .../es_search/shim_abort_signal.test.ts | 64 -------- .../search/es_search/shim_abort_signal.ts | 48 ------ .../common/search/es_search/to_snake_case.ts | 24 --- .../data/common/search/es_search/types.ts | 6 - src/plugins/data/common/search/index.ts | 1 + src/plugins/data/common/search/utils.test.ts | 106 +++++++++++++ .../common/search/{es_search => }/utils.ts | 2 +- src/plugins/data/public/public.api.md | 4 +- .../public/search/errors/painless_error.tsx | 3 +- .../public/search/search_interceptor.test.ts | 21 ++- .../data/public/search/search_interceptor.ts | 11 +- src/plugins/data/server/index.ts | 26 +-- .../data/server/search/collectors/index.ts | 3 +- .../data/server/search/collectors/usage.ts | 21 ++- .../search/es_search/es_search_rxjs_utils.ts | 53 ------- .../search/es_search/es_search_strategy.ts | 38 ++--- .../es_search/get_default_search_params.ts | 41 ----- .../data/server/search/es_search/index.ts | 5 +- .../search/es_search/request_utils.test.ts | 148 ++++++++++++++++++ .../server/search/es_search/request_utils.ts | 66 ++++++++ .../search/es_search/response_utils.test.ts | 69 ++++++++ .../search/es_search/response_utils.ts} | 18 ++- src/plugins/data/server/search/index.ts | 2 +- .../data/server/search/routes/call_msearch.ts | 7 +- src/plugins/data/server/server.api.md | 104 ++++++------ x-pack/plugins/data_enhanced/common/index.ts | 5 +- .../search/es_search/es_search_rxjs_utils.ts | 51 ------ .../common/search/es_search/index.ts | 7 - .../data_enhanced/common/search/index.ts | 2 +- .../common/search/poll_search.ts | 31 ++++ .../data_enhanced/common/search/types.ts | 15 -- .../public/search/search_interceptor.test.ts | 10 +- .../public/search/search_interceptor.ts | 54 ++----- .../server/search/eql_search_strategy.test.ts | 2 +- .../server/search/eql_search_strategy.ts | 81 ++++------ .../server/search/es_search_strategy.ts | 115 ++++++-------- .../search/get_default_search_params.ts | 33 ---- .../server/search/request_utils.ts | 64 ++++++++ .../server/search/response_utils.ts | 38 +++++ .../data_enhanced/server/search/types.ts | 20 +++ .../security_solution/index.ts | 22 ++- .../server/search_strategy/timeline/index.ts | 22 ++- 58 files changed, 842 insertions(+), 855 deletions(-) delete mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md delete mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md delete mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md delete mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.md create mode 100644 docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchusageobserver.md delete mode 100644 src/plugins/data/common/search/es_search/es_search_rxjs_utils.ts delete mode 100644 src/plugins/data/common/search/es_search/get_total_loaded.test.ts delete mode 100644 src/plugins/data/common/search/es_search/shim_abort_signal.test.ts delete mode 100644 src/plugins/data/common/search/es_search/shim_abort_signal.ts delete mode 100644 src/plugins/data/common/search/es_search/to_snake_case.ts create mode 100644 src/plugins/data/common/search/utils.test.ts rename src/plugins/data/common/search/{es_search => }/utils.ts (96%) delete mode 100644 src/plugins/data/server/search/es_search/es_search_rxjs_utils.ts delete mode 100644 src/plugins/data/server/search/es_search/get_default_search_params.ts create mode 100644 src/plugins/data/server/search/es_search/request_utils.test.ts create mode 100644 src/plugins/data/server/search/es_search/request_utils.ts create mode 100644 src/plugins/data/server/search/es_search/response_utils.test.ts rename src/plugins/data/{common/search/es_search/get_total_loaded.ts => server/search/es_search/response_utils.ts} (69%) delete mode 100644 x-pack/plugins/data_enhanced/common/search/es_search/es_search_rxjs_utils.ts delete mode 100644 x-pack/plugins/data_enhanced/common/search/es_search/index.ts create mode 100644 x-pack/plugins/data_enhanced/common/search/poll_search.ts delete mode 100644 x-pack/plugins/data_enhanced/server/search/get_default_search_params.ts create mode 100644 x-pack/plugins/data_enhanced/server/search/request_utils.ts create mode 100644 x-pack/plugins/data_enhanced/server/search/response_utils.ts create mode 100644 x-pack/plugins/data_enhanced/server/search/types.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md index 051414eac758..5f43f8477cb9 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `PainlessError` class Signature: ```typescript -constructor(err: IEsError, request: IKibanaSearchRequest); +constructor(err: IEsError); ``` ## Parameters @@ -17,5 +17,4 @@ constructor(err: IEsError, request: IKibanaSearchRequest); | Parameter | Type | Description | | --- | --- | --- | | err | IEsError | | -| request | IKibanaSearchRequest | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md index 6ab32f3fb1df..c77b8b259136 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md @@ -14,7 +14,7 @@ export declare class PainlessError extends EsError | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(err, request)](./kibana-plugin-plugins-data-public.painlesserror._constructor_.md) | | Constructs a new instance of the PainlessError class | +| [(constructor)(err)](./kibana-plugin-plugins-data-public.painlesserror._constructor_.md) | | Constructs a new instance of the PainlessError class | ## Properties diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md index 1c8b6eb41a72..b5ac4a4e5388 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md @@ -7,7 +7,7 @@ Signature: ```typescript -protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; +protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; ``` ## Parameters @@ -15,7 +15,6 @@ protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal | Parameter | Type | Description | | --- | --- | --- | | e | any | | -| request | IKibanaSearchRequest | | | timeoutSignal | AbortSignal | | | options | ISearchOptions | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md index 40c7055e4c05..5f266e7d8bd8 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -27,7 +27,7 @@ export declare class SearchInterceptor | Method | Modifiers | Description | | --- | --- | --- | | [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | | -| [handleSearchError(e, request, timeoutSignal, options)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | +| [handleSearchError(e, timeoutSignal, options)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | | [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when cancelPending is called, when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | | [showError(e)](./kibana-plugin-plugins-data-public.searchinterceptor.showerror.md) | | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getdefaultsearchparams.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getdefaultsearchparams.md index 3d9191196aaf..19a4bbbbef86 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getdefaultsearchparams.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getdefaultsearchparams.md @@ -7,11 +7,7 @@ Signature: ```typescript -export declare function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient): Promise<{ - maxConcurrentShardRequests: number | undefined; - ignoreUnavailable: boolean; - trackTotalHits: boolean; -}>; +export declare function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient): Promise>; ``` ## Parameters @@ -22,9 +18,5 @@ export declare function getDefaultSearchParams(uiSettingsClient: IUiSettingsClie Returns: -`Promise<{ - maxConcurrentShardRequests: number | undefined; - ignoreUnavailable: boolean; - trackTotalHits: boolean; -}>` +`Promise>` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getshardtimeout.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getshardtimeout.md index d7e2a597ff33..87aa32608eb1 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getshardtimeout.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.getshardtimeout.md @@ -7,11 +7,7 @@ Signature: ```typescript -export declare function getShardTimeout(config: SharedGlobalConfig): { - timeout: string; -} | { - timeout?: undefined; -}; +export declare function getShardTimeout(config: SharedGlobalConfig): Pick; ``` ## Parameters @@ -22,9 +18,5 @@ export declare function getShardTimeout(config: SharedGlobalConfig): { Returns: -`{ - timeout: string; -} | { - timeout?: undefined; -}` +`Pick` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md deleted file mode 100644 index 8e1d5d01bb66..000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IEsRawSearchResponse](./kibana-plugin-plugins-data-server.iesrawsearchresponse.md) > [id](./kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md) - -## IEsRawSearchResponse.id property - -Signature: - -```typescript -id?: string; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md deleted file mode 100644 index da2a57a84ab2..000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IEsRawSearchResponse](./kibana-plugin-plugins-data-server.iesrawsearchresponse.md) > [is\_partial](./kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md) - -## IEsRawSearchResponse.is\_partial property - -Signature: - -```typescript -is_partial?: boolean; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md deleted file mode 100644 index 78b9e07b7789..000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IEsRawSearchResponse](./kibana-plugin-plugins-data-server.iesrawsearchresponse.md) > [is\_running](./kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md) - -## IEsRawSearchResponse.is\_running property - -Signature: - -```typescript -is_running?: boolean; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.md deleted file mode 100644 index 306c18dea9b0..000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iesrawsearchresponse.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IEsRawSearchResponse](./kibana-plugin-plugins-data-server.iesrawsearchresponse.md) - -## IEsRawSearchResponse interface - -Signature: - -```typescript -export interface IEsRawSearchResponse extends SearchResponse -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [id](./kibana-plugin-plugins-data-server.iesrawsearchresponse.id.md) | string | | -| [is\_partial](./kibana-plugin-plugins-data-server.iesrawsearchresponse.is_partial.md) | boolean | | -| [is\_running](./kibana-plugin-plugins-data-server.iesrawsearchresponse.is_running.md) | boolean | | - diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index d9f14950be0e..c85f294d162b 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -34,6 +34,7 @@ | [getTime(indexPattern, timeRange, options)](./kibana-plugin-plugins-data-server.gettime.md) | | | [parseInterval(interval)](./kibana-plugin-plugins-data-server.parseinterval.md) | | | [plugin(initializerContext)](./kibana-plugin-plugins-data-server.plugin.md) | Static code to be shared externally | +| [searchUsageObserver(logger, usage)](./kibana-plugin-plugins-data-server.searchusageobserver.md) | Rxjs observer for easily doing tap(searchUsageObserver(logger, usage)) in an rxjs chain. | | [shouldReadFieldFromDocValues(aggregatable, esType)](./kibana-plugin-plugins-data-server.shouldreadfieldfromdocvalues.md) | | | [usageProvider(core)](./kibana-plugin-plugins-data-server.usageprovider.md) | | @@ -45,7 +46,6 @@ | [EsQueryConfig](./kibana-plugin-plugins-data-server.esqueryconfig.md) | | | [FieldDescriptor](./kibana-plugin-plugins-data-server.fielddescriptor.md) | | | [FieldFormatConfig](./kibana-plugin-plugins-data-server.fieldformatconfig.md) | | -| [IEsRawSearchResponse](./kibana-plugin-plugins-data-server.iesrawsearchresponse.md) | | | [IEsSearchRequest](./kibana-plugin-plugins-data-server.iessearchrequest.md) | | | [IFieldSubType](./kibana-plugin-plugins-data-server.ifieldsubtype.md) | | | [IFieldType](./kibana-plugin-plugins-data-server.ifieldtype.md) | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md index 77abcacd7704..4f8a0beefa42 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.search.md @@ -8,24 +8,6 @@ ```typescript search: { - esSearch: { - utils: { - doSearch: (searchMethod: () => Promise, abortSignal?: AbortSignal | undefined) => import("rxjs").Observable; - shimAbortSignal: >(promise: T, signal: AbortSignal | undefined) => T; - trackSearchStatus: = import("./search").IEsSearchResponse>>(logger: import("src/core/server").Logger, usage?: import("./search").SearchUsage | undefined) => import("rxjs").UnaryFunction, import("rxjs").Observable>; - includeTotalLoaded: () => import("rxjs").OperatorFunction>, { - total: number; - loaded: number; - id?: string | undefined; - isRunning?: boolean | undefined; - isPartial?: boolean | undefined; - rawResponse: import("elasticsearch").SearchResponse; - }>; - toKibanaSearchResponse: = import("../common").IEsRawSearchResponse, KibanaResponse_1 extends import("../common").IKibanaSearchResponse = import("../common").IKibanaSearchResponse>() => import("rxjs").OperatorFunction, KibanaResponse_1>; - getTotalLoaded: typeof getTotalLoaded; - toSnakeCase: typeof toSnakeCase; - }; - }; aggs: { CidrMask: typeof CidrMask; dateHistogramInterval: typeof dateHistogramInterval; diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchusageobserver.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchusageobserver.md new file mode 100644 index 000000000000..5e03bb381527 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.searchusageobserver.md @@ -0,0 +1,31 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [searchUsageObserver](./kibana-plugin-plugins-data-server.searchusageobserver.md) + +## searchUsageObserver() function + +Rxjs observer for easily doing `tap(searchUsageObserver(logger, usage))` in an rxjs chain. + +Signature: + +```typescript +export declare function searchUsageObserver(logger: Logger, usage?: SearchUsage): { + next(response: IEsSearchResponse): void; + error(): void; +}; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| logger | Logger | | +| usage | SearchUsage | | + +Returns: + +`{ + next(response: IEsSearchResponse): void; + error(): void; +}` + diff --git a/src/plugins/data/common/search/es_search/es_search_rxjs_utils.ts b/src/plugins/data/common/search/es_search/es_search_rxjs_utils.ts deleted file mode 100644 index e3238ea62db5..000000000000 --- a/src/plugins/data/common/search/es_search/es_search_rxjs_utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { from } from 'rxjs'; -import { map } from 'rxjs/operators'; - -import type { SearchResponse } from 'elasticsearch'; -import type { ApiResponse } from '@elastic/elasticsearch'; - -import { shimAbortSignal } from './shim_abort_signal'; -import { getTotalLoaded } from './get_total_loaded'; - -import type { IEsRawSearchResponse } from './types'; -import type { IKibanaSearchResponse } from '../types'; - -export const doSearch = ( - searchMethod: () => Promise, - abortSignal?: AbortSignal -) => from(shimAbortSignal(searchMethod(), abortSignal)); - -export const toKibanaSearchResponse = < - SearchResponse extends IEsRawSearchResponse = IEsRawSearchResponse, - KibanaResponse extends IKibanaSearchResponse = IKibanaSearchResponse ->() => - map, KibanaResponse>( - (response) => - ({ - id: response.body.id, - isPartial: response.body.is_partial || false, - isRunning: response.body.is_running || false, - rawResponse: response.body, - } as KibanaResponse) - ); - -export const includeTotalLoaded = () => - map((response: IKibanaSearchResponse>) => ({ - ...response, - ...getTotalLoaded(response.rawResponse._shards), - })); diff --git a/src/plugins/data/common/search/es_search/get_total_loaded.test.ts b/src/plugins/data/common/search/es_search/get_total_loaded.test.ts deleted file mode 100644 index 74e2873ede76..000000000000 --- a/src/plugins/data/common/search/es_search/get_total_loaded.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { getTotalLoaded } from './get_total_loaded'; - -describe('getTotalLoaded', () => { - it('returns the total/loaded, not including skipped', () => { - const result = getTotalLoaded({ - successful: 10, - failed: 5, - skipped: 5, - total: 100, - }); - - expect(result).toEqual({ - total: 100, - loaded: 15, - }); - }); -}); diff --git a/src/plugins/data/common/search/es_search/index.ts b/src/plugins/data/common/search/es_search/index.ts index 555667a9f530..d8f7b5091eb8 100644 --- a/src/plugins/data/common/search/es_search/index.ts +++ b/src/plugins/data/common/search/es_search/index.ts @@ -18,8 +18,3 @@ */ export * from './types'; -export * from './utils'; -export * from './es_search_rxjs_utils'; -export * from './shim_abort_signal'; -export * from './to_snake_case'; -export * from './get_total_loaded'; diff --git a/src/plugins/data/common/search/es_search/shim_abort_signal.test.ts b/src/plugins/data/common/search/es_search/shim_abort_signal.test.ts deleted file mode 100644 index 61af8b4c782a..000000000000 --- a/src/plugins/data/common/search/es_search/shim_abort_signal.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { shimAbortSignal } from './shim_abort_signal'; - -const createSuccessTransportRequestPromise = ( - body: any, - { statusCode = 200 }: { statusCode?: number } = {} -) => { - const promise = Promise.resolve({ body, statusCode }) as any; - promise.abort = jest.fn(); - - return promise; -}; - -describe('shimAbortSignal', () => { - test('aborts the promise if the signal is aborted', () => { - const promise = createSuccessTransportRequestPromise({ - success: true, - }); - const controller = new AbortController(); - shimAbortSignal(promise, controller.signal); - controller.abort(); - - expect(promise.abort).toHaveBeenCalled(); - }); - - test('returns the original promise', async () => { - const promise = createSuccessTransportRequestPromise({ - success: true, - }); - const controller = new AbortController(); - const response = await shimAbortSignal(promise, controller.signal); - - expect(response).toEqual(expect.objectContaining({ body: { success: true } })); - }); - - test('allows the promise to be aborted manually', () => { - const promise = createSuccessTransportRequestPromise({ - success: true, - }); - const controller = new AbortController(); - const enhancedPromise = shimAbortSignal(promise, controller.signal); - - enhancedPromise.abort(); - expect(promise.abort).toHaveBeenCalled(); - }); -}); diff --git a/src/plugins/data/common/search/es_search/shim_abort_signal.ts b/src/plugins/data/common/search/es_search/shim_abort_signal.ts deleted file mode 100644 index 554a24e26881..000000000000 --- a/src/plugins/data/common/search/es_search/shim_abort_signal.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * @internal - * TransportRequestPromise extends base Promise with an "abort" method - */ -export interface TransportRequestPromise extends Promise { - abort?: () => void; -} - -/** - * - * @internal - * NOTE: Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 - * is resolved - * - * @param promise a TransportRequestPromise - * @param signal optional AbortSignal - * - * @returns a TransportRequestPromise that will be aborted if the signal is aborted - */ - -export const shimAbortSignal = >( - promise: T, - signal: AbortSignal | undefined -): T => { - if (signal) { - signal.addEventListener('abort', () => promise.abort && promise.abort()); - } - return promise; -}; diff --git a/src/plugins/data/common/search/es_search/to_snake_case.ts b/src/plugins/data/common/search/es_search/to_snake_case.ts deleted file mode 100644 index b222a56fbf60..000000000000 --- a/src/plugins/data/common/search/es_search/to_snake_case.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { mapKeys, snakeCase } from 'lodash'; - -export function toSnakeCase(obj: Record): Record { - return mapKeys(obj, (value, key) => snakeCase(key)); -} diff --git a/src/plugins/data/common/search/es_search/types.ts b/src/plugins/data/common/search/es_search/types.ts index 7d81cf42e186..7dbbd01d2cda 100644 --- a/src/plugins/data/common/search/es_search/types.ts +++ b/src/plugins/data/common/search/es_search/types.ts @@ -30,10 +30,4 @@ export interface IEsSearchRequest extends IKibanaSearchRequest extends SearchResponse { - id?: string; - is_partial?: boolean; - is_running?: boolean; -} - export type IEsSearchResponse = IKibanaSearchResponse>; diff --git a/src/plugins/data/common/search/index.ts b/src/plugins/data/common/search/index.ts index e650cf10db87..01944d6e37aa 100644 --- a/src/plugins/data/common/search/index.ts +++ b/src/plugins/data/common/search/index.ts @@ -24,3 +24,4 @@ export * from './search_source'; export * from './tabify'; export * from './types'; export * from './session'; +export * from './utils'; diff --git a/src/plugins/data/common/search/utils.test.ts b/src/plugins/data/common/search/utils.test.ts new file mode 100644 index 000000000000..94f7b14de4bc --- /dev/null +++ b/src/plugins/data/common/search/utils.test.ts @@ -0,0 +1,106 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isErrorResponse, isCompleteResponse, isPartialResponse } from './utils'; + +describe('utils', () => { + describe('isErrorResponse', () => { + it('returns `true` if the response is undefined', () => { + const isError = isErrorResponse(); + expect(isError).toBe(true); + }); + + it('returns `true` if the response is not running and partial', () => { + const isError = isErrorResponse({ + isPartial: true, + isRunning: false, + rawResponse: {}, + }); + expect(isError).toBe(true); + }); + + it('returns `false` if the response is running and partial', () => { + const isError = isErrorResponse({ + isPartial: true, + isRunning: true, + rawResponse: {}, + }); + expect(isError).toBe(false); + }); + + it('returns `false` if the response is complete', () => { + const isError = isErrorResponse({ + isPartial: false, + isRunning: false, + rawResponse: {}, + }); + expect(isError).toBe(false); + }); + }); + + describe('isCompleteResponse', () => { + it('returns `false` if the response is undefined', () => { + const isError = isCompleteResponse(); + expect(isError).toBe(false); + }); + + it('returns `false` if the response is running and partial', () => { + const isError = isCompleteResponse({ + isPartial: true, + isRunning: true, + rawResponse: {}, + }); + expect(isError).toBe(false); + }); + + it('returns `true` if the response is complete', () => { + const isError = isCompleteResponse({ + isPartial: false, + isRunning: false, + rawResponse: {}, + }); + expect(isError).toBe(true); + }); + }); + + describe('isPartialResponse', () => { + it('returns `false` if the response is undefined', () => { + const isError = isPartialResponse(); + expect(isError).toBe(false); + }); + + it('returns `true` if the response is running and partial', () => { + const isError = isPartialResponse({ + isPartial: true, + isRunning: true, + rawResponse: {}, + }); + expect(isError).toBe(true); + }); + + it('returns `false` if the response is complete', () => { + const isError = isPartialResponse({ + isPartial: false, + isRunning: false, + rawResponse: {}, + }); + expect(isError).toBe(false); + }); + }); +}); diff --git a/src/plugins/data/common/search/es_search/utils.ts b/src/plugins/data/common/search/utils.ts similarity index 96% rename from src/plugins/data/common/search/es_search/utils.ts rename to src/plugins/data/common/search/utils.ts index 6ed222ab0830..0d544a51c2d4 100644 --- a/src/plugins/data/common/search/es_search/utils.ts +++ b/src/plugins/data/common/search/utils.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { IKibanaSearchResponse } from '../types'; +import type { IKibanaSearchResponse } from './types'; /** * @returns true if response had an error while executing in ES diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 2c47ecb27184..12d6fc5ad32c 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1686,7 +1686,7 @@ export interface OptionedValueProp { // @public (undocumented) export class PainlessError extends EsError { // Warning: (ae-forgotten-export) The symbol "IEsError" needs to be exported by the entry point index.d.ts - constructor(err: IEsError, request: IKibanaSearchRequest); + constructor(err: IEsError); // (undocumented) getErrorMessage(application: ApplicationStart): JSX.Element; // (undocumented) @@ -2090,7 +2090,7 @@ export class SearchInterceptor { // (undocumented) protected getTimeoutMode(): TimeoutErrorMode; // (undocumented) - protected handleSearchError(e: any, request: IKibanaSearchRequest, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; + protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; // @internal protected pendingCount$: BehaviorSubject; // @internal (undocumented) diff --git a/src/plugins/data/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx index 282a602d358c..3cfe9f4278ba 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -25,11 +25,10 @@ import { ApplicationStart } from 'kibana/public'; import { IEsError, isEsError } from './types'; import { EsError } from './es_error'; import { getRootCause } from './utils'; -import { IKibanaSearchRequest } from '..'; export class PainlessError extends EsError { painlessStack?: string; - constructor(err: IEsError, request: IKibanaSearchRequest) { + constructor(err: IEsError) { super(err); } diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index 60274261da25..6e75f6e5eef9 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -65,20 +65,17 @@ describe('SearchInterceptor', () => { test('Renders a PainlessError', async () => { searchInterceptor.showError( - new PainlessError( - { - body: { - attributes: { - error: { - failed_shards: { - reason: 'bananas', - }, + new PainlessError({ + body: { + attributes: { + error: { + failed_shards: { + reason: 'bananas', }, }, - } as any, - }, - {} as any - ) + }, + } as any, + }) ); expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index 3fadb723b27c..e5abac0d48fe 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -93,12 +93,7 @@ export class SearchInterceptor { * @returns `Error` a search service specific error or the original error, if a specific error can't be recognized. * @internal */ - protected handleSearchError( - e: any, - request: IKibanaSearchRequest, - timeoutSignal: AbortSignal, - options?: ISearchOptions - ): Error { + protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error { if (timeoutSignal.aborted || get(e, 'body.message') === 'Request timed out') { // Handle a client or a server side timeout const err = new SearchTimeoutError(e, this.getTimeoutMode()); @@ -112,7 +107,7 @@ export class SearchInterceptor { return e; } else if (isEsError(e)) { if (isPainlessError(e)) { - return new PainlessError(e, request); + return new PainlessError(e); } else { return new EsError(e); } @@ -244,7 +239,7 @@ export class SearchInterceptor { this.pendingCount$.next(this.pendingCount$.getValue() + 1); return from(this.runSearch(request, { ...options, abortSignal: combinedSignal })).pipe( catchError((e: Error) => { - return throwError(this.handleSearchError(e, request, timeoutSignal, options)); + return throwError(this.handleSearchError(e, timeoutSignal, options)); }), finalize(() => { this.pendingCount$.next(this.pendingCount$.getValue() - 1); diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index b3fe412152c9..a233447cdf43 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -156,7 +156,6 @@ export { IndexPatternAttributes, UI_SETTINGS, IndexPattern, - IEsRawSearchResponse, } from '../common'; /** @@ -189,13 +188,6 @@ import { // tabify tabifyAggResponse, tabifyGetColumns, - // search - toSnakeCase, - shimAbortSignal, - doSearch, - includeTotalLoaded, - toKibanaSearchResponse, - getTotalLoaded, calcAutoIntervalLessThan, } from '../common'; @@ -243,27 +235,17 @@ export { SearchStrategyDependencies, getDefaultSearchParams, getShardTimeout, + getTotalLoaded, + toKibanaSearchResponse, shimHitsTotal, usageProvider, + searchUsageObserver, + shimAbortSignal, SearchUsage, } from './search'; -import { trackSearchStatus } from './search'; - // Search namespace export const search = { - esSearch: { - utils: { - doSearch, - shimAbortSignal, - trackSearchStatus, - includeTotalLoaded, - toKibanaSearchResponse, - // utils: - getTotalLoaded, - toSnakeCase, - }, - }, aggs: { CidrMask, dateHistogramInterval, diff --git a/src/plugins/data/server/search/collectors/index.ts b/src/plugins/data/server/search/collectors/index.ts index 417dc1c2012d..8ad6501d505e 100644 --- a/src/plugins/data/server/search/collectors/index.ts +++ b/src/plugins/data/server/search/collectors/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export { usageProvider, SearchUsage } from './usage'; +export type { SearchUsage } from './usage'; +export { usageProvider, searchUsageObserver } from './usage'; diff --git a/src/plugins/data/server/search/collectors/usage.ts b/src/plugins/data/server/search/collectors/usage.ts index e1be92aa13c3..948175a41cb6 100644 --- a/src/plugins/data/server/search/collectors/usage.ts +++ b/src/plugins/data/server/search/collectors/usage.ts @@ -17,8 +17,9 @@ * under the License. */ -import { CoreSetup } from 'kibana/server'; -import { Usage } from './register'; +import type { CoreSetup, Logger } from 'kibana/server'; +import type { IEsSearchResponse } from '../../../common'; +import type { Usage } from './register'; const SAVED_OBJECT_ID = 'search-telemetry'; @@ -74,3 +75,19 @@ export function usageProvider(core: CoreSetup): SearchUsage { trackSuccess: getTracker('successCount'), }; } + +/** + * Rxjs observer for easily doing `tap(searchUsageObserver(logger, usage))` in an rxjs chain. + */ +export function searchUsageObserver(logger: Logger, usage?: SearchUsage) { + return { + next(response: IEsSearchResponse) { + logger.debug(`trackSearchStatus:next ${response.rawResponse.took}`); + usage?.trackSuccess(response.rawResponse.took); + }, + error() { + logger.debug(`trackSearchStatus:error`); + usage?.trackError(); + }, + }; +} diff --git a/src/plugins/data/server/search/es_search/es_search_rxjs_utils.ts b/src/plugins/data/server/search/es_search/es_search_rxjs_utils.ts deleted file mode 100644 index 3ba2f9c4b269..000000000000 --- a/src/plugins/data/server/search/es_search/es_search_rxjs_utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { pipe } from 'rxjs'; -import { tap } from 'rxjs/operators'; - -import type { Logger, SearchResponse } from 'kibana/server'; -import type { SearchUsage } from '../collectors'; -import type { IEsSearchResponse, IKibanaSearchResponse } from '../../../common/search'; - -/** - * trackSearchStatus is a custom rxjs operator that can be used to track the progress of a search. - * @param Logger - * @param SearchUsage - */ -export const trackSearchStatus = < - KibanaResponse extends IKibanaSearchResponse = IEsSearchResponse> ->( - logger: Logger, - usage?: SearchUsage -) => { - return pipe( - tap( - (response: KibanaResponse) => { - const trackSuccessData = response.rawResponse.took; - - if (trackSuccessData !== undefined) { - logger.debug(`trackSearchStatus:next ${trackSuccessData}`); - usage?.trackSuccess(trackSuccessData); - } - }, - (err: any) => { - logger.debug(`trackSearchStatus:error ${err}`); - usage?.trackError(); - } - ) - ); -}; diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index 3e2d415eac16..620df9c8edcb 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -16,20 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { Observable } from 'rxjs'; -import { first } from 'rxjs/operators'; - -import type { Logger } from 'kibana/server'; -import type { ApiResponse } from '@elastic/elasticsearch'; -import type { SharedGlobalConfig } from 'kibana/server'; - -import { doSearch, includeTotalLoaded, toKibanaSearchResponse, toSnakeCase } from '../../../common'; -import { trackSearchStatus } from './es_search_rxjs_utils'; -import { getDefaultSearchParams, getShardTimeout } from '../es_search'; - +import { from, Observable } from 'rxjs'; +import { first, tap } from 'rxjs/operators'; +import type { SearchResponse } from 'elasticsearch'; +import type { Logger, SharedGlobalConfig } from 'kibana/server'; import type { ISearchStrategy } from '../types'; -import type { SearchUsage } from '../collectors/usage'; -import type { IEsRawSearchResponse } from '../../../common'; +import type { SearchUsage } from '../collectors'; +import { getDefaultSearchParams, getShardTimeout, shimAbortSignal } from './request_utils'; +import { toKibanaSearchResponse } from './response_utils'; +import { searchUsageObserver } from '../collectors/usage'; export const esSearchStrategyProvider = ( config$: Observable, @@ -43,19 +38,18 @@ export const esSearchStrategyProvider = ( throw new Error(`Unsupported index pattern type ${request.indexType}`); } - return doSearch>(async () => { + const search = async () => { const config = await config$.pipe(first()).toPromise(); - const params = toSnakeCase({ + const params = { ...(await getDefaultSearchParams(uiSettingsClient)), ...getShardTimeout(config), ...request.params, - }); + }; + const promise = esClient.asCurrentUser.search>(params); + const { body } = await shimAbortSignal(promise, abortSignal); + return toKibanaSearchResponse(body); + }; - return esClient.asCurrentUser.search(params); - }, abortSignal).pipe( - toKibanaSearchResponse(), - trackSearchStatus(logger, usage), - includeTotalLoaded() - ); + return from(search()).pipe(tap(searchUsageObserver(logger, usage))); }, }); diff --git a/src/plugins/data/server/search/es_search/get_default_search_params.ts b/src/plugins/data/server/search/es_search/get_default_search_params.ts deleted file mode 100644 index a01b0885abf3..000000000000 --- a/src/plugins/data/server/search/es_search/get_default_search_params.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { UI_SETTINGS } from '../../../common/constants'; -import type { SharedGlobalConfig, IUiSettingsClient } from '../../../../../core/server'; - -export function getShardTimeout(config: SharedGlobalConfig) { - const timeout = config.elasticsearch.shardTimeout.asMilliseconds(); - return timeout - ? { - timeout: `${timeout}ms`, - } - : {}; -} - -export async function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient) { - const maxConcurrentShardRequests = await uiSettingsClient.get( - UI_SETTINGS.COURIER_MAX_CONCURRENT_SHARD_REQUESTS - ); - return { - maxConcurrentShardRequests: - maxConcurrentShardRequests > 0 ? maxConcurrentShardRequests : undefined, - ignoreUnavailable: true, // Don't fail if the index/indices don't exist - trackTotalHits: true, - }; -} diff --git a/src/plugins/data/server/search/es_search/index.ts b/src/plugins/data/server/search/es_search/index.ts index 14e8a4e1b024..f6487e3ef84f 100644 --- a/src/plugins/data/server/search/es_search/index.ts +++ b/src/plugins/data/server/search/es_search/index.ts @@ -18,7 +18,6 @@ */ export { esSearchStrategyProvider } from './es_search_strategy'; -export * from './get_default_search_params'; -export * from './es_search_rxjs_utils'; - +export * from './request_utils'; +export * from './response_utils'; export { ES_SEARCH_STRATEGY, IEsSearchRequest, IEsSearchResponse } from '../../../common'; diff --git a/src/plugins/data/server/search/es_search/request_utils.test.ts b/src/plugins/data/server/search/es_search/request_utils.test.ts new file mode 100644 index 000000000000..b63a6b3ae7e9 --- /dev/null +++ b/src/plugins/data/server/search/es_search/request_utils.test.ts @@ -0,0 +1,148 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getShardTimeout, getDefaultSearchParams, shimAbortSignal } from './request_utils'; +import { IUiSettingsClient, SharedGlobalConfig } from 'kibana/server'; + +const createSuccessTransportRequestPromise = ( + body: any, + { statusCode = 200 }: { statusCode?: number } = {} +) => { + const promise = Promise.resolve({ body, statusCode }) as any; + promise.abort = jest.fn(); + + return promise; +}; + +describe('request utils', () => { + describe('getShardTimeout', () => { + test('returns an empty object if the config does not contain a value', () => { + const result = getShardTimeout(({ + elasticsearch: { + shardTimeout: { + asMilliseconds: jest.fn(), + }, + }, + } as unknown) as SharedGlobalConfig); + expect(result).toEqual({}); + }); + + test('returns an empty object if the config contains 0', () => { + const result = getShardTimeout(({ + elasticsearch: { + shardTimeout: { + asMilliseconds: jest.fn().mockReturnValue(0), + }, + }, + } as unknown) as SharedGlobalConfig); + expect(result).toEqual({}); + }); + + test('returns a duration if the config >= 0', () => { + const result = getShardTimeout(({ + elasticsearch: { + shardTimeout: { + asMilliseconds: jest.fn().mockReturnValue(10), + }, + }, + } as unknown) as SharedGlobalConfig); + expect(result).toEqual({ timeout: '10ms' }); + }); + }); + + describe('getDefaultSearchParams', () => { + describe('max_concurrent_shard_requests', () => { + test('returns value if > 0', async () => { + const result = await getDefaultSearchParams(({ + get: jest.fn().mockResolvedValue(1), + } as unknown) as IUiSettingsClient); + expect(result).toHaveProperty('max_concurrent_shard_requests', 1); + }); + + test('returns undefined if === 0', async () => { + const result = await getDefaultSearchParams(({ + get: jest.fn().mockResolvedValue(0), + } as unknown) as IUiSettingsClient); + expect(result.max_concurrent_shard_requests).toBe(undefined); + }); + + test('returns undefined if undefined', async () => { + const result = await getDefaultSearchParams(({ + get: jest.fn(), + } as unknown) as IUiSettingsClient); + expect(result.max_concurrent_shard_requests).toBe(undefined); + }); + }); + + describe('other defaults', () => { + test('returns ignore_unavailable and track_total_hits', async () => { + const result = await getDefaultSearchParams(({ + get: jest.fn(), + } as unknown) as IUiSettingsClient); + expect(result).toHaveProperty('ignore_unavailable', true); + expect(result).toHaveProperty('track_total_hits', true); + }); + }); + }); + + describe('shimAbortSignal', () => { + test('aborts the promise if the signal is already aborted', async () => { + const promise = createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + controller.abort(); + shimAbortSignal(promise, controller.signal); + + expect(promise.abort).toHaveBeenCalled(); + }); + + test('aborts the promise if the signal is aborted', () => { + const promise = createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + shimAbortSignal(promise, controller.signal); + controller.abort(); + + expect(promise.abort).toHaveBeenCalled(); + }); + + test('returns the original promise', async () => { + const promise = createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + const response = await shimAbortSignal(promise, controller.signal); + + expect(response).toEqual(expect.objectContaining({ body: { success: true } })); + }); + + test('allows the promise to be aborted manually', () => { + const promise = createSuccessTransportRequestPromise({ + success: true, + }); + const controller = new AbortController(); + const enhancedPromise = shimAbortSignal(promise, controller.signal); + + enhancedPromise.abort(); + expect(promise.abort).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/data/server/search/es_search/request_utils.ts b/src/plugins/data/server/search/es_search/request_utils.ts new file mode 100644 index 000000000000..03b7db7da8ff --- /dev/null +++ b/src/plugins/data/server/search/es_search/request_utils.ts @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; +import type { Search } from '@elastic/elasticsearch/api/requestParams'; +import type { IUiSettingsClient, SharedGlobalConfig } from 'kibana/server'; +import { UI_SETTINGS } from '../../../common'; + +export function getShardTimeout(config: SharedGlobalConfig): Pick { + const timeout = config.elasticsearch.shardTimeout.asMilliseconds(); + return timeout ? { timeout: `${timeout}ms` } : {}; +} + +export async function getDefaultSearchParams( + uiSettingsClient: IUiSettingsClient +): Promise< + Pick +> { + const maxConcurrentShardRequests = await uiSettingsClient.get( + UI_SETTINGS.COURIER_MAX_CONCURRENT_SHARD_REQUESTS + ); + return { + max_concurrent_shard_requests: + maxConcurrentShardRequests > 0 ? maxConcurrentShardRequests : undefined, + ignore_unavailable: true, // Don't fail if the index/indices don't exist + track_total_hits: true, + }; +} + +/** + * Temporary workaround until https://github.com/elastic/elasticsearch-js/issues/1297 is resolved. + * Shims the `AbortSignal` behavior so that, if the given `signal` aborts, the `abort` method on the + * `TransportRequestPromise` is called, actually performing the cancellation. + * @internal + */ +export const shimAbortSignal = (promise: TransportRequestPromise, signal?: AbortSignal) => { + if (!signal) return promise; + const abortHandler = () => { + promise.abort(); + cleanup(); + }; + const cleanup = () => signal.removeEventListener('abort', abortHandler); + if (signal.aborted) { + promise.abort(); + } else { + signal.addEventListener('abort', abortHandler); + promise.then(cleanup, cleanup); + } + return promise; +}; diff --git a/src/plugins/data/server/search/es_search/response_utils.test.ts b/src/plugins/data/server/search/es_search/response_utils.test.ts new file mode 100644 index 000000000000..f93625980a69 --- /dev/null +++ b/src/plugins/data/server/search/es_search/response_utils.test.ts @@ -0,0 +1,69 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getTotalLoaded, toKibanaSearchResponse } from './response_utils'; +import { SearchResponse } from 'elasticsearch'; + +describe('response utils', () => { + describe('getTotalLoaded', () => { + it('returns the total/loaded, not including skipped', () => { + const result = getTotalLoaded(({ + _shards: { + successful: 10, + failed: 5, + skipped: 5, + total: 100, + }, + } as unknown) as SearchResponse); + + expect(result).toEqual({ + total: 100, + loaded: 15, + }); + }); + }); + + describe('toKibanaSearchResponse', () => { + it('returns rawResponse, isPartial, isRunning, total, and loaded', () => { + const result = toKibanaSearchResponse(({ + _shards: { + successful: 10, + failed: 5, + skipped: 5, + total: 100, + }, + } as unknown) as SearchResponse); + + expect(result).toEqual({ + rawResponse: { + _shards: { + successful: 10, + failed: 5, + skipped: 5, + total: 100, + }, + }, + isRunning: false, + isPartial: false, + total: 100, + loaded: 15, + }); + }); + }); +}); diff --git a/src/plugins/data/common/search/es_search/get_total_loaded.ts b/src/plugins/data/server/search/es_search/response_utils.ts similarity index 69% rename from src/plugins/data/common/search/es_search/get_total_loaded.ts rename to src/plugins/data/server/search/es_search/response_utils.ts index 233bcf818666..2f502f55057b 100644 --- a/src/plugins/data/common/search/es_search/get_total_loaded.ts +++ b/src/plugins/data/server/search/es_search/response_utils.ts @@ -17,14 +17,28 @@ * under the License. */ -import type { ShardsResponse } from 'elasticsearch'; +import { SearchResponse } from 'elasticsearch'; /** * Get the `total`/`loaded` for this response (see `IKibanaSearchResponse`). Note that `skipped` is * not included as it is already included in `successful`. * @internal */ -export function getTotalLoaded({ total, failed, successful }: ShardsResponse) { +export function getTotalLoaded(response: SearchResponse) { + const { total, failed, successful } = response._shards; const loaded = failed + successful; return { total, loaded }; } + +/** + * Get the Kibana representation of this response (see `IKibanaSearchResponse`). + * @internal + */ +export function toKibanaSearchResponse(rawResponse: SearchResponse) { + return { + rawResponse, + isPartial: false, + isRunning: false, + ...getTotalLoaded(rawResponse), + }; +} diff --git a/src/plugins/data/server/search/index.ts b/src/plugins/data/server/search/index.ts index 1be641401b29..3001bbe3c2f3 100644 --- a/src/plugins/data/server/search/index.ts +++ b/src/plugins/data/server/search/index.ts @@ -19,6 +19,6 @@ export * from './types'; export * from './es_search'; -export { usageProvider, SearchUsage } from './collectors'; +export { usageProvider, SearchUsage, searchUsageObserver } from './collectors'; export * from './aggs'; export { shimHitsTotal } from './routes'; diff --git a/src/plugins/data/server/search/routes/call_msearch.ts b/src/plugins/data/server/search/routes/call_msearch.ts index 603b3ed867b2..923369297889 100644 --- a/src/plugins/data/server/search/routes/call_msearch.ts +++ b/src/plugins/data/server/search/routes/call_msearch.ts @@ -24,9 +24,8 @@ import { SearchResponse } from 'elasticsearch'; import { IUiSettingsClient, IScopedClusterClient, SharedGlobalConfig } from 'src/core/server'; import type { MsearchRequestBody, MsearchResponse } from '../../../common/search/search_source'; -import { toSnakeCase, shimAbortSignal } from '../../../common/search/es_search'; import { shimHitsTotal } from './shim_hits_total'; -import { getShardTimeout, getDefaultSearchParams } from '..'; +import { getShardTimeout, getDefaultSearchParams, shimAbortSignal } from '..'; /** @internal */ export function convertRequestBody( @@ -71,7 +70,7 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { const timeout = getShardTimeout(config); // trackTotalHits is not supported by msearch - const { trackTotalHits, ...defaultParams } = await getDefaultSearchParams(uiSettings); + const { track_total_hits: _, ...defaultParams } = await getDefaultSearchParams(uiSettings); const body = convertRequestBody(params.body, timeout); @@ -81,7 +80,7 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { body, }, { - querystring: toSnakeCase(defaultParams), + querystring: defaultParams, } ), params.signal diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 6870ad5e2402..73e2a68cb966 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -34,6 +34,7 @@ import { IScopedClusterClient } from 'src/core/server'; import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public'; import { ISearchSource } from 'src/plugins/data/public'; import { IUiSettingsClient } from 'src/core/server'; +import { IUiSettingsClient as IUiSettingsClient_3 } from 'kibana/server'; import { KibanaRequest } from 'src/core/server'; import { LegacyAPICaller } from 'src/core/server'; import { Logger } from 'src/core/server'; @@ -58,8 +59,9 @@ import { SavedObjectsClientContract as SavedObjectsClientContract_2 } from 'kiba import { Search } from '@elastic/elasticsearch/api/requestParams'; import { SearchResponse } from 'elasticsearch'; import { SerializedFieldFormat as SerializedFieldFormat_2 } from 'src/plugins/expressions/common'; -import { ShardsResponse } from 'elasticsearch'; +import { SharedGlobalConfig as SharedGlobalConfig_2 } from 'kibana/server'; import { ToastInputFields } from 'src/core/public/notifications'; +import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; import { UiStatsMetricType } from '@kbn/analytics'; @@ -410,25 +412,15 @@ export function getCapabilitiesForRollupIndices(indices: { [key: string]: any; }; -// Warning: (ae-forgotten-export) The symbol "IUiSettingsClient" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "getDefaultSearchParams" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient_2): Promise<{ - maxConcurrentShardRequests: number | undefined; - ignoreUnavailable: boolean; - trackTotalHits: boolean; -}>; +export function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient_3): Promise>; -// Warning: (ae-forgotten-export) The symbol "SharedGlobalConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "getShardTimeout" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function getShardTimeout(config: SharedGlobalConfig): { - timeout: string; -} | { - timeout?: undefined; -}; +export function getShardTimeout(config: SharedGlobalConfig_2): Pick; // Warning: (ae-forgotten-export) The symbol "IIndexPattern" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "getTime" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -439,6 +431,12 @@ export function getTime(indexPattern: IIndexPattern | undefined, timeRange: Time fieldName?: string; }): import("../..").RangeFilter | undefined; +// @internal +export function getTotalLoaded(response: SearchResponse): { + total: number; + loaded: number; +}; + // Warning: (ae-missing-release-tag) "IAggConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -455,18 +453,6 @@ export type IAggConfigs = AggConfigs; // @public (undocumented) export type IAggType = AggType; -// Warning: (ae-missing-release-tag) "IEsRawSearchResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export interface IEsRawSearchResponse extends SearchResponse { - // (undocumented) - id?: string; - // (undocumented) - is_partial?: boolean; - // (undocumented) - is_running?: boolean; -} - // Warning: (ae-forgotten-export) The symbol "IKibanaSearchRequest" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ISearchRequestParams" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "IEsSearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1040,24 +1026,6 @@ export interface RefreshInterval { // // @public (undocumented) export const search: { - esSearch: { - utils: { - doSearch: (searchMethod: () => Promise, abortSignal?: AbortSignal | undefined) => import("rxjs").Observable; - shimAbortSignal: >(promise: T, signal: AbortSignal | undefined) => T; - trackSearchStatus: = import("./search").IEsSearchResponse>>(logger: import("src/core/server").Logger, usage?: import("./search").SearchUsage | undefined) => import("rxjs").UnaryFunction, import("rxjs").Observable>; - includeTotalLoaded: () => import("rxjs").OperatorFunction>, { - total: number; - loaded: number; - id?: string | undefined; - isRunning?: boolean | undefined; - isPartial?: boolean | undefined; - rawResponse: import("elasticsearch").SearchResponse; - }>; - toKibanaSearchResponse: = import("../common").IEsRawSearchResponse, KibanaResponse_1 extends import("../common").IKibanaSearchResponse = import("../common").IKibanaSearchResponse>() => import("rxjs").OperatorFunction, KibanaResponse_1>; - getTotalLoaded: typeof getTotalLoaded; - toSnakeCase: typeof toSnakeCase; - }; - }; aggs: { CidrMask: typeof CidrMask; dateHistogramInterval: typeof dateHistogramInterval; @@ -1114,6 +1082,17 @@ export interface SearchUsage { trackSuccess(duration: number): Promise; } +// Warning: (ae-missing-release-tag) "searchUsageObserver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function searchUsageObserver(logger: Logger_2, usage?: SearchUsage): { + next(response: IEsSearchResponse): void; + error(): void; +}; + +// @internal +export const shimAbortSignal: (promise: TransportRequestPromise, signal?: AbortSignal | undefined) => TransportRequestPromise; + // @internal export function shimHitsTotal(response: SearchResponse): { hits: { @@ -1176,6 +1155,15 @@ export type TimeRange = { mode?: 'absolute' | 'relative'; }; +// @internal +export function toKibanaSearchResponse(rawResponse: SearchResponse): { + total: number; + loaded: number; + rawResponse: SearchResponse; + isPartial: boolean; + isRunning: boolean; +}; + // Warning: (ae-missing-release-tag) "UI_SETTINGS" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1247,22 +1235,20 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:111:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:137:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:137:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:254:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:269:5 - (ae-forgotten-export) The symbol "getTotalLoaded" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:270:5 - (ae-forgotten-export) The symbol "toSnakeCase" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:274:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:275:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:284:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:285:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:286:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:290:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:291:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:295:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:298:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:299:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:248:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:248:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:248:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:248:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:250:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:251:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:260:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:261:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:262:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:266:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:267:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:271:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:274:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:275:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index_patterns/index_patterns_service.ts:58:14 - (ae-forgotten-export) The symbol "IndexPatternsService" needs to be exported by the entry point index.d.ts // src/plugins/data/server/plugin.ts:88:66 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts // src/plugins/data/server/search/types.ts:104:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts diff --git a/x-pack/plugins/data_enhanced/common/index.ts b/x-pack/plugins/data_enhanced/common/index.ts index 61767af03080..dd1a2d39ab5d 100644 --- a/x-pack/plugins/data_enhanced/common/index.ts +++ b/x-pack/plugins/data_enhanced/common/index.ts @@ -10,9 +10,6 @@ export { EqlRequestParams, EqlSearchStrategyRequest, EqlSearchStrategyResponse, - IAsyncSearchRequest, - IEnhancedEsSearchRequest, IAsyncSearchOptions, - doPartialSearch, - throwOnEsError, + pollSearch, } from './search'; diff --git a/x-pack/plugins/data_enhanced/common/search/es_search/es_search_rxjs_utils.ts b/x-pack/plugins/data_enhanced/common/search/es_search/es_search_rxjs_utils.ts deleted file mode 100644 index 8b25a59ed857..000000000000 --- a/x-pack/plugins/data_enhanced/common/search/es_search/es_search_rxjs_utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { of, merge, timer, throwError } from 'rxjs'; -import { map, takeWhile, switchMap, expand, mergeMap, tap } from 'rxjs/operators'; -import { ApiResponse } from '@elastic/elasticsearch'; - -import { - doSearch, - IKibanaSearchResponse, - isErrorResponse, -} from '../../../../../../src/plugins/data/common'; -import { AbortError } from '../../../../../../src/plugins/kibana_utils/common'; -import type { IKibanaSearchRequest } from '../../../../../../src/plugins/data/common'; -import type { IAsyncSearchOptions } from '../../../common/search/types'; - -const DEFAULT_POLLING_INTERVAL = 1000; - -export const doPartialSearch = ( - searchMethod: () => Promise, - partialSearchMethod: (id: IKibanaSearchRequest['id']) => Promise, - isCompleteResponse: (response: SearchResponse) => boolean, - getId: (response: SearchResponse) => IKibanaSearchRequest['id'], - requestId: IKibanaSearchRequest['id'], - { abortSignal, pollInterval = DEFAULT_POLLING_INTERVAL }: IAsyncSearchOptions -) => - doSearch( - requestId ? () => partialSearchMethod(requestId) : searchMethod, - abortSignal - ).pipe( - tap((response) => (requestId = getId(response))), - expand(() => timer(pollInterval).pipe(switchMap(() => partialSearchMethod(requestId)))), - takeWhile((response) => !isCompleteResponse(response), true) - ); - -export const normalizeEqlResponse = () => - map((eqlResponse) => ({ - ...eqlResponse, - body: { - ...eqlResponse.body, - ...eqlResponse, - }, - })); - -export const throwOnEsError = () => - mergeMap((r: IKibanaSearchResponse) => - isErrorResponse(r) ? merge(of(r), throwError(new AbortError())) : of(r) - ); diff --git a/x-pack/plugins/data_enhanced/common/search/es_search/index.ts b/x-pack/plugins/data_enhanced/common/search/es_search/index.ts deleted file mode 100644 index bbf9f14ba63c..000000000000 --- a/x-pack/plugins/data_enhanced/common/search/es_search/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export * from './es_search_rxjs_utils'; diff --git a/x-pack/plugins/data_enhanced/common/search/index.ts b/x-pack/plugins/data_enhanced/common/search/index.ts index 44f82386e35c..34bb21cb91af 100644 --- a/x-pack/plugins/data_enhanced/common/search/index.ts +++ b/x-pack/plugins/data_enhanced/common/search/index.ts @@ -5,4 +5,4 @@ */ export * from './types'; -export * from './es_search'; +export * from './poll_search'; diff --git a/x-pack/plugins/data_enhanced/common/search/poll_search.ts b/x-pack/plugins/data_enhanced/common/search/poll_search.ts new file mode 100644 index 000000000000..c0e289c691cf --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/search/poll_search.ts @@ -0,0 +1,31 @@ +/* + * 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, NEVER, Observable, timer } from 'rxjs'; +import { expand, finalize, switchMap, takeUntil, takeWhile, tap } from 'rxjs/operators'; +import type { IKibanaSearchResponse } from '../../../../../src/plugins/data/common'; +import { isErrorResponse, isPartialResponse } from '../../../../../src/plugins/data/common'; +import { AbortError, abortSignalToPromise } from '../../../../../src/plugins/kibana_utils/common'; +import type { IAsyncSearchOptions } from './types'; + +export const pollSearch = ( + search: () => Promise, + { pollInterval = 1000, ...options }: IAsyncSearchOptions = {} +): Observable => { + const aborted = options?.abortSignal + ? abortSignalToPromise(options?.abortSignal) + : { promise: NEVER, cleanup: () => {} }; + + return from(search()).pipe( + expand(() => timer(pollInterval).pipe(switchMap(search))), + tap((response) => { + if (isErrorResponse(response)) throw new AbortError(); + }), + takeWhile(isPartialResponse, true), + takeUntil(from(aborted.promise)), + finalize(aborted.cleanup) + ); +}; diff --git a/x-pack/plugins/data_enhanced/common/search/types.ts b/x-pack/plugins/data_enhanced/common/search/types.ts index 4abf8351114f..f017462d4050 100644 --- a/x-pack/plugins/data_enhanced/common/search/types.ts +++ b/x-pack/plugins/data_enhanced/common/search/types.ts @@ -9,27 +9,12 @@ import { ApiResponse, TransportRequestOptions } from '@elastic/elasticsearch/lib import { ISearchOptions, - IEsSearchRequest, IKibanaSearchRequest, IKibanaSearchResponse, } from '../../../../../src/plugins/data/common'; export const ENHANCED_ES_SEARCH_STRATEGY = 'ese'; -export interface IAsyncSearchRequest extends IEsSearchRequest { - /** - * The ID received from the response from the initial request - */ - id?: string; -} - -export interface IEnhancedEsSearchRequest extends IEsSearchRequest { - /** - * Used to determine whether to use the _rollups_search or a regular search endpoint. - */ - isRollup?: boolean; -} - export const EQL_SEARCH_STRATEGY = 'eql'; export type EqlRequestParams = EqlSearch>; diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 044489d58eb0..3f1cfc7a010c 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -117,7 +117,7 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - isPartial: false, + isPartial: true, isRunning: true, id: 1, rawResponse: { @@ -175,8 +175,6 @@ describe('EnhancedSearchInterceptor', () => { await timeTravel(10); - expect(next).toHaveBeenCalled(); - expect(next.mock.calls[0][0]).toStrictEqual(responses[0].value); expect(error).toHaveBeenCalled(); expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); }); @@ -212,7 +210,7 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - isPartial: false, + isPartial: true, isRunning: true, id: 1, }, @@ -280,7 +278,7 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - isPartial: false, + isPartial: true, isRunning: true, id: 1, }, @@ -320,7 +318,7 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - isPartial: false, + isPartial: true, isRunning: true, id: 1, }, diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts index e1bd71caddb4..9aa35b460b1e 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -4,24 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { throwError, from, Subscription } from 'rxjs'; -import { tap, takeUntil, finalize, catchError } from 'rxjs/operators'; +import { throwError, Subscription } from 'rxjs'; +import { tap, finalize, catchError } from 'rxjs/operators'; import { TimeoutErrorMode, - IEsSearchResponse, SearchInterceptor, SearchInterceptorDeps, UI_SETTINGS, + IKibanaSearchRequest, } from '../../../../../src/plugins/data/public'; -import { AbortError, abortSignalToPromise } from '../../../../../src/plugins/kibana_utils/public'; - -import { - IAsyncSearchRequest, - ENHANCED_ES_SEARCH_STRATEGY, - IAsyncSearchOptions, - doPartialSearch, - throwOnEsError, -} from '../../common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { ENHANCED_ES_SEARCH_STRATEGY, IAsyncSearchOptions, pollSearch } from '../../common'; export class EnhancedSearchInterceptor extends SearchInterceptor { private uiSettingsSub: Subscription; @@ -60,49 +53,26 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { if (this.deps.usageCollector) this.deps.usageCollector.trackQueriesCancelled(); }; - public search( - request: IAsyncSearchRequest, - { pollInterval = 1000, ...options }: IAsyncSearchOptions = {} - ) { - let { id } = request; - + public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { const { combinedSignal, timeoutSignal, cleanup } = this.setupAbortSignal({ abortSignal: options.abortSignal, timeout: this.searchTimeout, }); - const abortedPromise = abortSignalToPromise(combinedSignal); const strategy = options?.strategy ?? ENHANCED_ES_SEARCH_STRATEGY; + const searchOptions = { ...options, strategy, abortSignal: combinedSignal }; + const search = () => this.runSearch({ id, ...request }, searchOptions); this.pendingCount$.next(this.pendingCount$.getValue() + 1); - return doPartialSearch( - () => this.runSearch(request, { ...options, strategy, abortSignal: combinedSignal }), - (requestId) => - this.runSearch( - { ...request, id: requestId }, - { ...options, strategy, abortSignal: combinedSignal } - ), - (r) => !r.isRunning, - (response) => response.id, - id, - { pollInterval } - ).pipe( - tap((r) => { - id = r.id ?? id; - }), - throwOnEsError(), - takeUntil(from(abortedPromise.promise)), + return pollSearch(search, { ...options, abortSignal: combinedSignal }).pipe( + tap((response) => (id = response.id)), catchError((e: AbortError) => { - if (id) { - this.deps.http.delete(`/internal/search/${strategy}/${id}`); - } - - return throwError(this.handleSearchError(e, request, timeoutSignal, options)); + if (id) this.deps.http.delete(`/internal/search/${strategy}/${id}`); + return throwError(this.handleSearchError(e, timeoutSignal, options)); }), finalize(() => { this.pendingCount$.next(this.pendingCount$.getValue() - 1); cleanup(); - abortedPromise.cleanup(); }) ); } diff --git a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts index cd94d91db8c5..f2d7725954a2 100644 --- a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts @@ -178,7 +178,7 @@ describe('EQL search strategy', () => { expect(requestOptions).toEqual( expect.objectContaining({ - max_retries: 2, + maxRetries: 2, ignore: [300], }) ); diff --git a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts index 7b3d0db450b0..26325afc378f 100644 --- a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts @@ -4,21 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { tap } from 'rxjs/operators'; import type { Logger } from 'kibana/server'; -import type { ApiResponse } from '@elastic/elasticsearch'; - -import { search } from '../../../../../src/plugins/data/server'; -import { - doPartialSearch, - normalizeEqlResponse, -} from '../../common/search/es_search/es_search_rxjs_utils'; -import { getAsyncOptions, getDefaultSearchParams } from './get_default_search_params'; - -import type { ISearchStrategy, IEsRawSearchResponse } from '../../../../../src/plugins/data/server'; +import type { ISearchStrategy } from '../../../../../src/plugins/data/server'; import type { EqlSearchStrategyRequest, EqlSearchStrategyResponse, -} from '../../common/search/types'; + IAsyncSearchOptions, +} from '../../common'; +import { getDefaultSearchParams, shimAbortSignal } from '../../../../../src/plugins/data/server'; +import { pollSearch } from '../../common'; +import { getDefaultAsyncGetParams, getIgnoreThrottled } from './request_utils'; +import { toEqlKibanaSearchResponse } from './response_utils'; +import { EqlSearchResponse } from './types'; export const eqlSearchStrategyProvider = ( logger: Logger @@ -26,48 +24,37 @@ export const eqlSearchStrategyProvider = ( return { cancel: async (id, options, { esClient }) => { logger.debug(`_eql/delete ${id}`); - await esClient.asCurrentUser.eql.delete({ - id, - }); + await esClient.asCurrentUser.eql.delete({ id }); }, - search: (request, options, { esClient, uiSettingsClient }) => { - logger.debug(`_eql/search ${JSON.stringify(request.params) || request.id}`); + search: ({ id, ...request }, options: IAsyncSearchOptions, { esClient, uiSettingsClient }) => { + logger.debug(`_eql/search ${JSON.stringify(request.params) || id}`); - const { utils } = search.esSearch; - const asyncOptions = getAsyncOptions(); - const requestOptions = utils.toSnakeCase({ ...request.options }); const client = esClient.asCurrentUser.eql; - return doPartialSearch>( - async () => { - const { ignoreThrottled, ignoreUnavailable } = await getDefaultSearchParams( - uiSettingsClient - ); - - return client.search( - utils.toSnakeCase({ - ignoreThrottled, - ignoreUnavailable, - ...asyncOptions, + const search = async () => { + const { track_total_hits: _, ...defaultParams } = await getDefaultSearchParams( + uiSettingsClient + ); + const params = id + ? getDefaultAsyncGetParams() + : { + ...(await getIgnoreThrottled(uiSettingsClient)), + ...defaultParams, + ...getDefaultAsyncGetParams(), ...request.params, - }) as EqlSearchStrategyRequest['params'], - requestOptions - ); - }, - (id) => - client.get( - { - id: id!, - ...utils.toSnakeCase(asyncOptions), - }, - requestOptions - ), - (response) => !response.body.is_running, - (response) => response.body.id, - request.id, - options - ).pipe(normalizeEqlResponse(), utils.toKibanaSearchResponse()); + }; + const promise = id + ? client.get({ ...params, id }, request.options) + : client.search( + params as EqlSearchStrategyRequest['params'], + request.options + ); + const response = await shimAbortSignal(promise, options.abortSignal); + return toEqlKibanaSearchResponse(response); + }; + + return pollSearch(search, options).pipe(tap((response) => (id = response.id))); }, }; }; diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts index 2070610ceb20..e1c7d7b5fc22 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -4,86 +4,67 @@ * you may not use this file except in compliance with the Elastic License. */ +import type { Observable } from 'rxjs'; +import type { Logger, SharedGlobalConfig } from 'kibana/server'; +import { first, tap } from 'rxjs/operators'; +import { SearchResponse } from 'elasticsearch'; import { from } from 'rxjs'; -import { first, map } from 'rxjs/operators'; -import { Observable } from 'rxjs'; - -import type { SearchResponse } from 'elasticsearch'; -import type { ApiResponse } from '@elastic/elasticsearch'; - -import { - getShardTimeout, - shimHitsTotal, - search, - SearchStrategyDependencies, -} from '../../../../../src/plugins/data/server'; -import { doPartialSearch } from '../../common/search/es_search/es_search_rxjs_utils'; -import { getDefaultSearchParams, getAsyncOptions } from './get_default_search_params'; - -import type { SharedGlobalConfig, Logger } from '../../../../../src/core/server'; - import type { + IEsSearchRequest, + IEsSearchResponse, + ISearchOptions, ISearchStrategy, + SearchStrategyDependencies, SearchUsage, - IEsRawSearchResponse, - ISearchOptions, - IEsSearchResponse, } from '../../../../../src/plugins/data/server'; - -import type { IEnhancedEsSearchRequest } from '../../common'; - -const { utils } = search.esSearch; - -interface IEsRawAsyncSearchResponse extends IEsRawSearchResponse { - response: SearchResponse; -} +import { + getDefaultSearchParams, + getShardTimeout, + getTotalLoaded, + searchUsageObserver, + shimAbortSignal, +} from '../../../../../src/plugins/data/server'; +import type { IAsyncSearchOptions } from '../../common'; +import { pollSearch } from '../../common'; +import { + getDefaultAsyncGetParams, + getDefaultAsyncSubmitParams, + getIgnoreThrottled, +} from './request_utils'; +import { toAsyncKibanaSearchResponse } from './response_utils'; +import { AsyncSearchResponse } from './types'; export const enhancedEsSearchStrategyProvider = ( config$: Observable, logger: Logger, usage?: SearchUsage -): ISearchStrategy => { +): ISearchStrategy => { function asyncSearch( - request: IEnhancedEsSearchRequest, - options: ISearchOptions, + { id, ...request }: IEsSearchRequest, + options: IAsyncSearchOptions, { esClient, uiSettingsClient }: SearchStrategyDependencies ) { - const asyncOptions = getAsyncOptions(); const client = esClient.asCurrentUser.asyncSearch; - return doPartialSearch>( - async () => - client.submit( - utils.toSnakeCase({ - ...(await getDefaultSearchParams(uiSettingsClient)), - batchedReduceSize: 64, - keepOnCompletion: !!options.sessionId, // Always return an ID, even if the request completes quickly - ...asyncOptions, - ...request.params, - }) - ), - (id) => - client.get({ - id: id!, - ...utils.toSnakeCase({ ...asyncOptions }), - }), - (response) => !response.body.is_running, - (response) => response.body.id, - request.id, - options - ).pipe( - utils.toKibanaSearchResponse(), - map((response) => ({ - ...response, - rawResponse: shimHitsTotal(response.rawResponse.response!), - })), - utils.trackSearchStatus(logger, usage), - utils.includeTotalLoaded() + const search = async () => { + const params = id + ? getDefaultAsyncGetParams() + : { ...(await getDefaultAsyncSubmitParams(uiSettingsClient, options)), ...request.params }; + const promise = id + ? client.get({ ...params, id }) + : client.submit(params); + const { body } = await shimAbortSignal(promise, options.abortSignal); + return toAsyncKibanaSearchResponse(body); + }; + + return pollSearch(search, options).pipe( + tap((response) => (id = response.id)), + tap(searchUsageObserver(logger, usage)) ); } async function rollupSearch( - request: IEnhancedEsSearchRequest, + request: IEsSearchRequest, options: ISearchOptions, { esClient, uiSettingsClient }: SearchStrategyDependencies ): Promise { @@ -91,11 +72,12 @@ export const enhancedEsSearchStrategyProvider = ( const { body, index, ...params } = request.params!; const method = 'POST'; const path = encodeURI(`/${index}/_rollup_search`); - const querystring = utils.toSnakeCase({ + const querystring = { ...getShardTimeout(config), + ...(await getIgnoreThrottled(uiSettingsClient)), ...(await getDefaultSearchParams(uiSettingsClient)), ...params, - }); + }; const promise = esClient.asCurrentUser.transport.request({ method, @@ -104,17 +86,16 @@ export const enhancedEsSearchStrategyProvider = ( querystring, }); - const esResponse = await utils.shimAbortSignal(promise, options?.abortSignal); - + const esResponse = await shimAbortSignal(promise, options?.abortSignal); const response = esResponse.body as SearchResponse; return { rawResponse: response, - ...utils.getTotalLoaded(response._shards), + ...getTotalLoaded(response), }; } return { - search: (request, options, deps) => { + search: (request, options: IAsyncSearchOptions, deps) => { logger.debug(`search ${JSON.stringify(request.params) || request.id}`); return request.indexType !== 'rollup' diff --git a/x-pack/plugins/data_enhanced/server/search/get_default_search_params.ts b/x-pack/plugins/data_enhanced/server/search/get_default_search_params.ts deleted file mode 100644 index fdda78798808..000000000000 --- a/x-pack/plugins/data_enhanced/server/search/get_default_search_params.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { IUiSettingsClient } from 'src/core/server'; -import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; - -import { getDefaultSearchParams as getBaseSearchParams } from '../../../../../src/plugins/data/server'; - -/** - @internal - */ -export async function getDefaultSearchParams(uiSettingsClient: IUiSettingsClient) { - const ignoreThrottled = !(await uiSettingsClient.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN)); - - return { - ignoreThrottled, - ...(await getBaseSearchParams(uiSettingsClient)), - }; -} - -/** - @internal - */ -export const getAsyncOptions = (): { - waitForCompletionTimeout: string; - keepAlive: string; -} => ({ - waitForCompletionTimeout: '100ms', // Wait up to 100ms for the response to return - keepAlive: '1m', // Extend the TTL for this search request by one minute, -}); diff --git a/x-pack/plugins/data_enhanced/server/search/request_utils.ts b/x-pack/plugins/data_enhanced/server/search/request_utils.ts new file mode 100644 index 000000000000..f54ab2199c90 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/request_utils.ts @@ -0,0 +1,64 @@ +/* + * 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 { IUiSettingsClient } from 'kibana/server'; +import { + AsyncSearchGet, + AsyncSearchSubmit, + Search, +} from '@elastic/elasticsearch/api/requestParams'; +import { ISearchOptions, UI_SETTINGS } from '../../../../../src/plugins/data/common'; +import { getDefaultSearchParams } from '../../../../../src/plugins/data/server'; + +/** + * @internal + */ +export async function getIgnoreThrottled( + uiSettingsClient: IUiSettingsClient +): Promise> { + const includeFrozen = await uiSettingsClient.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); + return { ignore_throttled: !includeFrozen }; +} + +/** + @internal + */ +export async function getDefaultAsyncSubmitParams( + uiSettingsClient: IUiSettingsClient, + options: ISearchOptions +): Promise< + Pick< + AsyncSearchSubmit, + | 'batched_reduce_size' + | 'keep_alive' + | 'wait_for_completion_timeout' + | 'ignore_throttled' + | 'max_concurrent_shard_requests' + | 'ignore_unavailable' + | 'track_total_hits' + | 'keep_on_completion' + > +> { + return { + batched_reduce_size: 64, + keep_on_completion: !!options.sessionId, // Always return an ID, even if the request completes quickly + ...getDefaultAsyncGetParams(), + ...(await getIgnoreThrottled(uiSettingsClient)), + ...(await getDefaultSearchParams(uiSettingsClient)), + }; +} + +/** + @internal + */ +export function getDefaultAsyncGetParams(): Pick< + AsyncSearchGet, + 'keep_alive' | 'wait_for_completion_timeout' +> { + return { + keep_alive: '1m', // Extend the TTL for this search request by one minute + wait_for_completion_timeout: '100ms', // Wait up to 100ms for the response to return + }; +} diff --git a/x-pack/plugins/data_enhanced/server/search/response_utils.ts b/x-pack/plugins/data_enhanced/server/search/response_utils.ts new file mode 100644 index 000000000000..716e7d72d80e --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/response_utils.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ApiResponse } from '@elastic/elasticsearch'; +import { getTotalLoaded } from '../../../../../src/plugins/data/server'; +import { AsyncSearchResponse, EqlSearchResponse } from './types'; +import { EqlSearchStrategyResponse } from '../../common/search'; + +/** + * Get the Kibana representation of an async search response (see `IKibanaSearchResponse`). + */ +export function toAsyncKibanaSearchResponse(response: AsyncSearchResponse) { + return { + id: response.id, + rawResponse: response.response, + isPartial: response.is_partial, + isRunning: response.is_running, + ...getTotalLoaded(response.response), + }; +} + +/** + * Get the Kibana representation of an EQL search response (see `IKibanaSearchResponse`). + * (EQL does not provide _shard info, so total/loaded cannot be calculated.) + */ +export function toEqlKibanaSearchResponse( + response: ApiResponse +): EqlSearchStrategyResponse { + return { + id: response.body.id, + rawResponse: response, + isPartial: response.body.is_partial, + isRunning: response.body.is_running, + }; +} diff --git a/x-pack/plugins/data_enhanced/server/search/types.ts b/x-pack/plugins/data_enhanced/server/search/types.ts new file mode 100644 index 000000000000..f01ac51a1516 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/search/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; + +export interface AsyncSearchResponse { + id?: string; + response: SearchResponse; + is_partial: boolean; + is_running: boolean; +} + +export interface EqlSearchResponse extends SearchResponse { + id?: string; + is_partial: boolean; + is_running: boolean; +} diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts index baacad65e140..8b2cce01cf07 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts @@ -4,8 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mergeMap } from 'rxjs/operators'; -import { ISearchStrategy, PluginStart } from '../../../../../../src/plugins/data/server'; +import { map, mergeMap } from 'rxjs/operators'; +import { + ISearchStrategy, + PluginStart, + shimHitsTotal, +} from '../../../../../../src/plugins/data/server'; import { ENHANCED_ES_SEARCH_STRATEGY } from '../../../../data_enhanced/common'; import { FactoryQueryTypes, @@ -28,9 +32,17 @@ export const securitySolutionSearchStrategyProvider = = securitySolutionFactory[request.factoryQueryType]; const dsl = queryFactory.buildDsl(request); - return es - .search({ ...request, params: dsl }, options, deps) - .pipe(mergeMap((esSearchRes) => queryFactory.parse(request, esSearchRes))); + return es.search({ ...request, params: dsl }, options, deps).pipe( + map((response) => { + return { + ...response, + ...{ + rawResponse: shimHitsTotal(response.rawResponse), + }, + }; + }), + mergeMap((esSearchRes) => queryFactory.parse(request, esSearchRes)) + ); }, cancel: async (id, options, deps) => { if (es.cancel) { diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/index.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/index.ts index 29ad37e76264..5ad00a727c3b 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/index.ts @@ -4,8 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mergeMap } from 'rxjs/operators'; -import { ISearchStrategy, PluginStart } from '../../../../../../src/plugins/data/server'; +import { map, mergeMap } from 'rxjs/operators'; +import { + ISearchStrategy, + PluginStart, + shimHitsTotal, +} from '../../../../../../src/plugins/data/server'; import { ENHANCED_ES_SEARCH_STRATEGY } from '../../../../data_enhanced/common'; import { TimelineFactoryQueryTypes, @@ -29,9 +33,17 @@ export const securitySolutionTimelineSearchStrategyProvider = queryFactory.parse(request, esSearchRes))); + return es.search({ ...request, params: dsl }, options, deps).pipe( + map((response) => { + return { + ...response, + ...{ + rawResponse: shimHitsTotal(response.rawResponse), + }, + }; + }), + mergeMap((esSearchRes) => queryFactory.parse(request, esSearchRes)) + ); }, cancel: async (id, options, deps) => { if (es.cancel) { From 5f53d856c093a50f68932b81647e6332dbe0ace4 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 24 Nov 2020 17:54:26 -0500 Subject: [PATCH 47/89] [Fleet] Only display log level in Filter that are present in the logs (#84277) --- .../components/agent_logs/constants.tsx | 2 ++ .../components/agent_logs/filter_log_level.tsx | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx index 41069e710786..ea98de356024 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx @@ -32,4 +32,6 @@ export const AGENT_LOG_LEVELS = { DEBUG: 'debug', }; +export const ORDERED_FILTER_LOG_LEVELS = ['error', 'warning', 'warn', 'notice', 'info', 'debug']; + export const DEFAULT_LOG_LEVEL = AGENT_LOG_LEVELS.INFO; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx index a45831b2bbd2..6aee9e065a96 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/filter_log_level.tsx @@ -6,10 +6,19 @@ import React, { memo, useState, useEffect } from 'react'; import { EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AGENT_LOG_LEVELS, AGENT_LOG_INDEX_PATTERN, LOG_LEVEL_FIELD } from './constants'; +import { ORDERED_FILTER_LOG_LEVELS, AGENT_LOG_INDEX_PATTERN, LOG_LEVEL_FIELD } from './constants'; import { useStartServices } from '../../../../../hooks'; -const LEVEL_VALUES = Object.values(AGENT_LOG_LEVELS); +function sortLogLevels(levels: string[]): string[] { + return [ + ...new Set([ + // order by severity for known level + ...ORDERED_FILTER_LOG_LEVELS.filter((level) => levels.includes(level)), + // Add unknown log level + ...levels.sort(), + ]), + ]; +} export const LogLevelFilter: React.FunctionComponent<{ selectedLevels: string[]; @@ -18,7 +27,7 @@ export const LogLevelFilter: React.FunctionComponent<{ const { data } = useStartServices(); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [levelValues, setLevelValues] = useState(LEVEL_VALUES); + const [levelValues, setLevelValues] = useState([]); useEffect(() => { const fetchValues = async () => { @@ -32,7 +41,7 @@ export const LogLevelFilter: React.FunctionComponent<{ field: LOG_LEVEL_FIELD, query: '', }); - setLevelValues([...new Set([...LEVEL_VALUES, ...values.sort()])]); + setLevelValues(sortLogLevels(values)); } catch (e) { setLevelValues([]); } From 2ce1e09aad408d352a8802cec46f868453574f34 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Tue, 24 Nov 2020 17:03:56 -0600 Subject: [PATCH 48/89] [Security Solution][Detections] Rule Form Fixes (#84169) * Prevent error from being displayed when choosing action throttle Addresses #83230. This field was previously refactored to not require a callback prop; simply updating the value via `field.setValue` is sufficient for our use case. This fix removes the errant code that assumed a callback prop, since such a prop does not exist on the underlying component. * Fix UI bug on ML Jobs popover EUI links now add an SVG if they're an external link; our use of a div was causing that to wrap. Since the div was only needed to change the text size, a refactor makes this all work. * Exercise editing of tags in E2E tests These tests were recently skipped due to their improper teardown. Since that's a broader issue across most of these tests, I'm reopening these so we can get the coverage provided here for the time being. * useFetchIndex defaults to isLoading: false In the case where no index pattern is provided, the hook exits without doing any work but does not update the loading state; this had the downstream effect of disabling a form field that was waiting for this hook to stop loading. * Move situational action into ... the situation We only need to clear existing tags in the case where we're editing the rule (and it has tags); in all other cases, this method fails. This fixes things by moving that conditional logic (clear the tags field) into the test that needs it (editing custom rules). --- .../integration/alerts_detection_rules_custom.spec.ts | 5 +++-- x-pack/plugins/security_solution/cypress/objects/rule.ts | 1 + .../security_solution/cypress/screens/create_new_rule.ts | 3 +++ .../components/ml_popover/jobs_table/jobs_table.tsx | 8 +++++--- .../public/common/containers/source/index.tsx | 2 +- .../components/rules/throttle_select_field/index.tsx | 7 +++---- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index 596b92d06405..dfe984cba381 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -38,6 +38,7 @@ import { SCHEDULE_INTERVAL_AMOUNT_INPUT, SCHEDULE_INTERVAL_UNITS_INPUT, SEVERITY_DROPDOWN, + TAGS_CLEAR_BUTTON, TAGS_FIELD, } from '../screens/create_new_rule'; import { @@ -215,8 +216,7 @@ describe('Custom detection rules creation', () => { }); }); -// FLAKY: https://github.com/elastic/kibana/issues/83772 -describe.skip('Custom detection rules deletion and edition', () => { +describe('Custom detection rules deletion and edition', () => { beforeEach(() => { esArchiverLoad('custom_rules'); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); @@ -328,6 +328,7 @@ describe.skip('Custom detection rules deletion and edition', () => { cy.get(ACTIONS_THROTTLE_INPUT).invoke('val').should('eql', 'no_actions'); goToAboutStepTab(); + cy.get(TAGS_CLEAR_BUTTON).click({ force: true }); fillAboutRule(editedRule); saveEditedRule(); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 0bb4c8e35609..8ba545e242b4 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -265,4 +265,5 @@ export const editedRule = { ...existingRule, severity: 'Medium', description: 'Edited Rule description', + tags: [...existingRule.tags, 'edited'], }; diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index 618ddbad9f44..d802e97363a6 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -146,6 +146,9 @@ export const TAGS_FIELD = export const TAGS_INPUT = '[data-test-subj="detectionEngineStepAboutRuleTags"] [data-test-subj="comboBoxSearchInput"]'; +export const TAGS_CLEAR_BUTTON = + '[data-test-subj="detectionEngineStepAboutRuleTags"] [data-test-subj="comboBoxClearButton"]'; + export const THRESHOLD_FIELD_SELECTION = '.euiFilterSelectItem'; export const THRESHOLD_INPUT_AREA = '[data-test-subj="thresholdInput"]'; diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx index 5e3045efe1f4..54a2381ecf58 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx @@ -57,9 +57,11 @@ const JobName = ({ id, description, basePath }: JobNameProps) => { return ( - - {id} - + + + {id} + + {description.length > truncateThreshold ? `${description.substring(0, truncateThreshold)}...` diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index 7e73a40f2f74..f245857f3d0d 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -126,7 +126,7 @@ export const useFetchIndex = ( const { data, notifications } = useKibana().services; const abortCtrl = useRef(new AbortController()); const previousIndexesName = useRef([]); - const [isLoading, setLoading] = useState(true); + const [isLoading, setLoading] = useState(false); const [state, setState] = useState({ browserFields: DEFAULT_BROWSER_FIELDS, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/throttle_select_field/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/throttle_select_field/index.tsx index bf3498b28cd4..f0326913909b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/throttle_select_field/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/throttle_select_field/index.tsx @@ -25,14 +25,13 @@ export const DEFAULT_THROTTLE_OPTION = THROTTLE_OPTIONS[0]; type ThrottleSelectField = typeof SelectField; export const ThrottleSelectField: ThrottleSelectField = (props) => { + const { setValue } = props.field; const onChange = useCallback( (e) => { const throttle = e.target.value; - props.field.setValue(throttle); - props.handleChange(throttle); + setValue(throttle); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.field.setValue, props.handleChange] + [setValue] ); const newEuiFieldProps = { ...props.euiFieldProps, onChange }; return ; From a1b568b604c1db9db7f319f0247048d67c649f16 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Tue, 24 Nov 2020 16:32:46 -0700 Subject: [PATCH 49/89] Changes code ownership from kibana-telemetry to kibana-core (#84281) --- .github/CODEOWNERS | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a536d1b54551..bb4c50028302 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -204,6 +204,28 @@ #CC# /x-pack/plugins/features/ @elastic/kibana-core #CC# /x-pack/plugins/global_search/ @elastic/kibana-core +# Kibana Telemetry +/packages/kbn-analytics/ @elastic/kibana-core +/packages/kbn-telemetry-tools/ @elastic/kibana-core +/src/plugins/kibana_usage_collection/ @elastic/kibana-core +/src/plugins/newsfeed/ @elastic/kibana-core +/src/plugins/telemetry/ @elastic/kibana-core +/src/plugins/telemetry_collection_manager/ @elastic/kibana-core +/src/plugins/telemetry_management_section/ @elastic/kibana-core +/src/plugins/usage_collection/ @elastic/kibana-core +/x-pack/plugins/telemetry_collection_xpack/ @elastic/kibana-core +/.telemetryrc.json @elastic/kibana-core +/x-pack/.telemetryrc.json @elastic/kibana-core +src/plugins/telemetry/schema/legacy_oss_plugins.json @elastic/kibana-core +src/plugins/telemetry/schema/oss_plugins.json @elastic/kibana-core +x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kibana-core + +# Kibana Localization +/src/dev/i18n/ @elastic/kibana-localization @elastic/kibana-core +/src/core/public/i18n/ @elastic/kibana-localization @elastic/kibana-core +/packages/kbn-i18n/ @elastic/kibana-localization @elastic/kibana-core +#CC# /x-pack/plugins/translations/ @elastic/kibana-localization @elastic/kibana-core + # Security /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-core /src/plugins/security_oss/ @elastic/kibana-security @@ -221,28 +243,6 @@ #CC# /x-pack/plugins/security_solution/ @elastic/kibana-security #CC# /x-pack/plugins/security/ @elastic/kibana-security -# Kibana Localization -/src/dev/i18n/ @elastic/kibana-localization -/src/core/public/i18n/ @elastic/kibana-localization -/packages/kbn-i18n/ @elastic/kibana-localization -#CC# /x-pack/plugins/translations/ @elastic/kibana-localization - -# Kibana Telemetry -/packages/kbn-analytics/ @elastic/kibana-telemetry -/packages/kbn-telemetry-tools/ @elastic/kibana-telemetry -/src/plugins/kibana_usage_collection/ @elastic/kibana-telemetry -/src/plugins/newsfeed/ @elastic/kibana-telemetry -/src/plugins/telemetry/ @elastic/kibana-telemetry -/src/plugins/telemetry_collection_manager/ @elastic/kibana-telemetry -/src/plugins/telemetry_management_section/ @elastic/kibana-telemetry -/src/plugins/usage_collection/ @elastic/kibana-telemetry -/x-pack/plugins/telemetry_collection_xpack/ @elastic/kibana-telemetry -/.telemetryrc.json @elastic/kibana-telemetry -/x-pack/.telemetryrc.json @elastic/kibana-telemetry -src/plugins/telemetry/schema/legacy_oss_plugins.json @elastic/kibana-telemetry -src/plugins/telemetry/schema/oss_plugins.json @elastic/kibana-telemetry -x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kibana-telemetry - # Kibana Alerting Services /x-pack/plugins/alerts/ @elastic/kibana-alerting-services /x-pack/plugins/actions/ @elastic/kibana-alerting-services From aa8ec78060765cb614eb46346a099d0560ce22b6 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 25 Nov 2020 00:18:18 +0000 Subject: [PATCH 50/89] chore(NA): enable yarn prefer offline and local mirror for development (#84124) * chore(NA): enable --prefer-offline by default * chore(NA): use prefer offline in the yarnrc * chore(NA): update kbn pm * chore(NA): add yarn offline mirror integration * chore(NA): remove non wanted prune feature due to switching between branches * chore(NA): re-introduce babel require hook --- .ci/packer_cache_for_branch.sh | 3 ++- .gitignore | 3 +++ .yarnrc | 5 +++++ package.json | 6 +++--- src/dev/ci_setup/setup.sh | 2 +- vars/kibanaCoverage.groovy | 4 ++-- vars/kibanaTeamAssign.groovy | 2 +- yarn.lock | 6 +++--- 8 files changed, 20 insertions(+), 11 deletions(-) create mode 100644 .yarnrc diff --git a/.ci/packer_cache_for_branch.sh b/.ci/packer_cache_for_branch.sh index 0d9b22b04dbd..bc427bf927f1 100755 --- a/.ci/packer_cache_for_branch.sh +++ b/.ci/packer_cache_for_branch.sh @@ -49,7 +49,8 @@ tar -cf "$HOME/.kibana/bootstrap_cache/$branch.tar" \ .chromium \ .es \ .chromedriver \ - .geckodriver; + .geckodriver \ + .yarn-local-mirror; echo "created $HOME/.kibana/bootstrap_cache/$branch.tar" diff --git a/.gitignore b/.gitignore index 45034583cffb..b786a419383b 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,6 @@ report.asciidoc # TS incremental build cache *.tsbuildinfo + +# Yarn local mirror content +.yarn-local-mirror diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 000000000000..eceec9ca34a2 --- /dev/null +++ b/.yarnrc @@ -0,0 +1,5 @@ +# Configure an offline yarn mirror in the data folder +yarn-offline-mirror ".yarn-local-mirror" + +# Always look into the cache first before fetching online +--install.prefer-offline true diff --git a/package.json b/package.json index 571dc7302f92..ab1cb90c900a 100644 --- a/package.json +++ b/package.json @@ -243,7 +243,7 @@ "moment": "^2.24.0", "moment-duration-format": "^2.3.2", "moment-timezone": "^0.5.27", - "monaco-editor": "~0.17.0", + "monaco-editor": "^0.17.0", "mustache": "^2.3.2", "ngreact": "^0.5.1", "nock": "12.0.3", @@ -589,7 +589,7 @@ "babel-loader": "^8.0.6", "babel-plugin-add-module-exports": "^1.0.2", "babel-plugin-istanbul": "^6.0.0", - "babel-plugin-require-context-hook": "npm:babel-plugin-require-context-hook-babel7@1.0.0", + "babel-plugin-require-context-hook": "^1.0.0", "babel-plugin-styled-components": "^1.10.7", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "backport": "5.6.0", @@ -775,7 +775,7 @@ "react-fast-compare": "^2.0.4", "react-grid-layout": "^0.16.2", "react-markdown": "^4.3.1", - "react-monaco-editor": "~0.27.0", + "react-monaco-editor": "^0.27.0", "react-popper-tooltip": "^2.10.1", "react-resize-detector": "^4.2.0", "react-reverse-portal": "^1.0.4", diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index aabc1e75b902..61f578ba3397 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -14,7 +14,7 @@ echo " -- TEST_ES_SNAPSHOT_VERSION='$TEST_ES_SNAPSHOT_VERSION'" ### install dependencies ### echo " -- installing node.js dependencies" -yarn kbn bootstrap --prefer-offline +yarn kbn bootstrap ### ### Download es snapshots diff --git a/vars/kibanaCoverage.groovy b/vars/kibanaCoverage.groovy index c43be9fb17ee..521672e4bf48 100644 --- a/vars/kibanaCoverage.groovy +++ b/vars/kibanaCoverage.groovy @@ -145,7 +145,7 @@ def generateReports(title) { source src/dev/ci_setup/setup_env.sh true # bootstrap from x-pack folder cd x-pack - yarn kbn bootstrap --prefer-offline + yarn kbn bootstrap # Return to project root cd .. . src/dev/code_coverage/shell_scripts/extract_archives.sh @@ -172,7 +172,7 @@ def uploadCombinedReports() { def ingestData(jobName, buildNum, buildUrl, previousSha, teamAssignmentsPath, title) { kibanaPipeline.bash(""" source src/dev/ci_setup/setup_env.sh - yarn kbn bootstrap --prefer-offline + yarn kbn bootstrap # Using existing target/kibana-coverage folder . src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh '${jobName}' ${buildNum} '${buildUrl}' '${previousSha}' '${teamAssignmentsPath}' """, title) diff --git a/vars/kibanaTeamAssign.groovy b/vars/kibanaTeamAssign.groovy index caf1ee36e25a..590d3af4b7bf 100644 --- a/vars/kibanaTeamAssign.groovy +++ b/vars/kibanaTeamAssign.groovy @@ -1,7 +1,7 @@ def generateTeamAssignments(teamAssignmentsPath, title) { kibanaPipeline.bash(""" source src/dev/ci_setup/setup_env.sh - yarn kbn bootstrap --prefer-offline + yarn kbn bootstrap # Build team assignments dat file node scripts/generate_team_assignments.js --verbose --dest '${teamAssignmentsPath}' diff --git a/yarn.lock b/yarn.lock index dc171a44dca1..1cde1266ca38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7849,7 +7849,7 @@ babel-plugin-react-docgen@^4.1.0: react-docgen "^5.0.0" recast "^0.14.7" -babel-plugin-require-context-hook@^1.0.0, "babel-plugin-require-context-hook@npm:babel-plugin-require-context-hook-babel7@1.0.0": +babel-plugin-require-context-hook@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/babel-plugin-require-context-hook-babel7/-/babel-plugin-require-context-hook-babel7-1.0.0.tgz#1273d4cee7e343d0860966653759a45d727e815d" integrity sha512-kez0BAN/cQoyO1Yu1nre1bQSYZEF93Fg7VQiBHFfMWuaZTy7vJSTT4FY68FwHTYG53Nyt0A7vpSObSVxwweQeQ== @@ -20267,7 +20267,7 @@ moment-timezone@^0.5.27: resolved "https://registry.yarnpkg.com/moment/-/moment-2.28.0.tgz#cdfe73ce01327cee6537b0fafac2e0f21a237d75" integrity sha512-Z5KOjYmnHyd/ukynmFd/WwyXHd7L4J9vTI/nn5Ap9AVUgaAE15VvQ9MOGmJJygEUklupqIrFnor/tjTwRU+tQw== -monaco-editor@~0.17.0: +monaco-editor@^0.17.0: version "0.17.1" resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.17.1.tgz#8fbe96ca54bfa75262706e044f8f780e904aa45c" integrity sha512-JAc0mtW7NeO+0SwPRcdkfDbWLgkqL9WfP1NbpP9wNASsW6oWqgZqNIWt4teymGjZIXTElx3dnQmUYHmVrJ7HxA== @@ -23323,7 +23323,7 @@ react-moment-proptypes@^1.7.0: dependencies: moment ">=1.6.0" -react-monaco-editor@~0.27.0: +react-monaco-editor@^0.27.0: version "0.27.0" resolved "https://registry.yarnpkg.com/react-monaco-editor/-/react-monaco-editor-0.27.0.tgz#2dbf47b8fd4d8e4763934051f07291d9b128bb89" integrity sha512-Im40xO4DuFlQ6kVcSBHC+p70fD/5aErUy1uyLT9RZ4nlehn6BOPpwmcw/2IN/LfMvy8X4WmLuuvrNftBZLH+vA== From 80dfd91abf0840da99dc71f6534d6a997367ca19 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Wed, 25 Nov 2020 11:09:59 +0300 Subject: [PATCH 51/89] [TSVB] Remove jQuery dependency from plugins (#83809) * Use rxjs instead of jquery for eventBus. * Fix comments * Removed one check because property is private. * Resolve comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../lib/{active_cursor.js => active_cursor.ts} | 8 +++----- .../visualizations/views/timeseries/index.js | 10 +++++----- 2 files changed, 8 insertions(+), 10 deletions(-) rename src/plugins/vis_type_timeseries/public/application/visualizations/lib/{active_cursor.js => active_cursor.ts} (78%) diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.js b/src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.ts similarity index 78% rename from src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.js rename to src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.ts index 427ced4dc3f2..59a846aa66a0 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/lib/active_cursor.ts @@ -17,9 +17,7 @@ * under the License. */ -// TODO: Remove bus when action/triggers are available with LegacyPluginApi or metric is converted to Embeddable -import $ from 'jquery'; +import { Subject } from 'rxjs'; +import { PointerEvent } from '@elastic/charts'; -export const ACTIVE_CURSOR = 'ACTIVE_CURSOR'; - -export const eventBus = $({}); +export const activeCursor$ = new Subject(); diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index 36624cfeea0c..b13d82387a70 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -34,7 +34,7 @@ import { } from '@elastic/charts'; import { EuiIcon } from '@elastic/eui'; import { getTimezone } from '../../../lib/get_timezone'; -import { eventBus, ACTIVE_CURSOR } from '../../lib/active_cursor'; +import { activeCursor$ } from '../../lib/active_cursor'; import { getUISettings, getChartsSetup } from '../../../../services'; import { GRID_LINE_CONFIG, ICON_TYPES_MAP, STACKED_OPTIONS } from '../../constants'; import { AreaSeriesDecorator } from './decorators/area_decorator'; @@ -54,7 +54,7 @@ const generateAnnotationData = (values, formatter) => const decorateFormatter = (formatter) => ({ value }) => formatter(value); const handleCursorUpdate = (cursor) => { - eventBus.trigger(ACTIVE_CURSOR, cursor); + activeCursor$.next(cursor); }; export const TimeSeries = ({ @@ -73,16 +73,16 @@ export const TimeSeries = ({ const chartRef = useRef(); useEffect(() => { - const updateCursor = (_, cursor) => { + const updateCursor = (cursor) => { if (chartRef.current) { chartRef.current.dispatchExternalPointerEvent(cursor); } }; - eventBus.on(ACTIVE_CURSOR, updateCursor); + const subscription = activeCursor$.subscribe(updateCursor); return () => { - eventBus.off(ACTIVE_CURSOR, undefined, updateCursor); + subscription.unsubscribe(); }; }, []); From 8dfa489da26100a11408d3770a808842e4daf8b0 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Wed, 25 Nov 2020 09:28:20 +0100 Subject: [PATCH 52/89] [ILM] Relax POST policy route validation (#84203) * relax policy post route validation * update comment --- .../api/policies/register_create_route.ts | 116 ++---------------- 1 file changed, 11 insertions(+), 105 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts index d8e40e3b3041..8d21b1f16454 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts @@ -23,114 +23,20 @@ async function createPolicy(client: ElasticsearchClient, name: string, phases: a return client.ilm.putLifecycle({ policy: name, body }, options); } -const minAgeSchema = schema.maybe(schema.string()); - -const setPrioritySchema = schema.maybe( - schema.object({ - priority: schema.nullable(schema.number()), - }) -); - -const unfollowSchema = schema.maybe(schema.object({})); // Unfollow has no options - -const migrateSchema = schema.maybe(schema.object({ enabled: schema.literal(false) })); - -const allocateNodeSchema = schema.maybe(schema.recordOf(schema.string(), schema.string())); -const allocateSchema = schema.maybe( - schema.object({ - number_of_replicas: schema.maybe(schema.number()), - include: allocateNodeSchema, - exclude: allocateNodeSchema, - require: allocateNodeSchema, - }) -); - -const forcemergeSchema = schema.maybe( - schema.object({ - max_num_segments: schema.number(), - index_codec: schema.maybe(schema.literal('best_compression')), - }) -); - -const hotPhaseSchema = schema.object({ - min_age: minAgeSchema, - actions: schema.object({ - set_priority: setPrioritySchema, - unfollow: unfollowSchema, - rollover: schema.maybe( - schema.object({ - max_age: schema.maybe(schema.string()), - max_size: schema.maybe(schema.string()), - max_docs: schema.maybe(schema.number()), - }) - ), - forcemerge: forcemergeSchema, - }), -}); - -const warmPhaseSchema = schema.maybe( - schema.object({ - min_age: minAgeSchema, - actions: schema.object({ - migrate: migrateSchema, - set_priority: setPrioritySchema, - unfollow: unfollowSchema, - readonly: schema.maybe(schema.object({})), // Readonly has no options - allocate: allocateSchema, - shrink: schema.maybe( - schema.object({ - number_of_shards: schema.number(), - }) - ), - forcemerge: forcemergeSchema, - }), - }) -); - -const coldPhaseSchema = schema.maybe( - schema.object({ - min_age: minAgeSchema, - actions: schema.object({ - migrate: migrateSchema, - set_priority: setPrioritySchema, - unfollow: unfollowSchema, - allocate: allocateSchema, - freeze: schema.maybe(schema.object({})), // Freeze has no options - searchable_snapshot: schema.maybe( - schema.object({ - snapshot_repository: schema.string(), - }) - ), - }), - }) -); - -const deletePhaseSchema = schema.maybe( - schema.object({ - min_age: minAgeSchema, - actions: schema.object({ - wait_for_snapshot: schema.maybe( - schema.object({ - policy: schema.string(), - }) - ), - delete: schema.maybe( - schema.object({ - delete_searchable_snapshot: schema.maybe(schema.boolean()), - }) - ), - }), - }) -); - -// Per https://www.elastic.co/guide/en/elasticsearch/reference/current/_actions.html +/** + * We intentionally do not deeply validate the posted policy object to avoid erroring on valid ES + * policy configuration Kibana UI does not know or should not know about. For instance, the + * `force_merge_index` setting of the `searchable_snapshot` action. + * + * We only specify a rough structure based on https://www.elastic.co/guide/en/elasticsearch/reference/current/_actions.html. + */ const bodySchema = schema.object({ name: schema.string(), phases: schema.object({ - hot: hotPhaseSchema, - warm: warmPhaseSchema, - cold: coldPhaseSchema, - delete: deletePhaseSchema, + hot: schema.any(), + warm: schema.maybe(schema.any()), + cold: schema.maybe(schema.any()), + delete: schema.maybe(schema.any()), }), }); From a763d3302f8c52f681d4b4f81e244c3dc83242eb Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 25 Nov 2020 09:43:23 +0100 Subject: [PATCH 53/89] [Lens] Calculation operations (#83789) --- .../workspace_panel/workspace_panel.tsx | 2 +- .../public/indexpattern_datasource/index.ts | 2 + .../indexpattern.test.ts | 19 ++- .../indexpattern_datasource/indexpattern.tsx | 1 + .../definitions/calculations/counter_rate.tsx | 91 +++++++++++ .../calculations/cumulative_sum.tsx | 91 +++++++++++ .../definitions/calculations/derivative.tsx | 90 +++++++++++ .../definitions/calculations/index.ts | 10 ++ .../calculations/moving_average.tsx | 147 ++++++++++++++++++ .../definitions/calculations/utils.ts | 67 ++++++++ .../operations/definitions/column_types.ts | 2 +- .../operations/definitions/index.ts | 20 ++- .../operations/layer_helpers.ts | 11 +- .../operations/operations.test.ts | 16 ++ 14 files changed, 552 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 95aeedbd857c..6c2c01d944cd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -161,7 +161,7 @@ export function WorkspacePanel({ const expression = useMemo( () => { - if (!configurationValidationError) { + if (!configurationValidationError || configurationValidationError.length === 0) { try { return buildExpression({ visualization: activeVisualization, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts index 793f3387e707..5f7eddd807c9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts @@ -37,12 +37,14 @@ export class IndexPatternDatasource { getIndexPatternDatasource, renameColumns, formatColumn, + counterRate, getTimeScaleFunction, getSuffixFormatter, } = await import('../async_services'); return core.getStartServices().then(([coreStart, { data }]) => { data.fieldFormats.register([getSuffixFormatter(data.fieldFormats.deserialize)]); expressions.registerFunction(getTimeScaleFunction(data)); + expressions.registerFunction(counterRate); expressions.registerFunction(renameColumns); expressions.registerFunction(formatColumn); return getIndexPatternDatasource({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 3cf9bdc3a92f..c3247b251d88 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -661,19 +661,30 @@ describe('IndexPattern Data Source', () => { it('should skip columns that are being referenced', () => { publicAPI = indexPatternDatasource.getPublicAPI({ state: { + ...enrichBaseState(baseState), layers: { first: { indexPatternId: '1', columnOrder: ['col1', 'col2'], columns: { - // @ts-ignore this is too little information for a real column col1: { + label: 'Sum', dataType: 'number', - }, + isBucketed: false, + + operationType: 'sum', + sourceField: 'test', + params: {}, + } as IndexPatternColumn, col2: { - // @ts-expect-error update once we have a reference operation outside tests + label: 'Cumulative sum', + dataType: 'number', + isBucketed: false, + + operationType: 'cumulative_sum', references: ['col1'], - }, + params: {}, + } as IndexPatternColumn, }, }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 2c64431867df..289b6bbe3f25 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -78,6 +78,7 @@ export function columnToOperation(column: IndexPatternColumn, uniqueLabel?: stri export * from './rename_columns'; export * from './format_column'; export * from './time_scale'; +export * from './counter_rate'; export * from './suffix_formatter'; export function getIndexPatternDatasource({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx new file mode 100644 index 000000000000..d256b74696a4 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -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 { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types'; +import { IndexPatternLayer } from '../../../types'; +import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils'; +import { OperationDefinition } from '..'; + +const ofName = (name?: string) => { + return i18n.translate('xpack.lens.indexPattern.CounterRateOf', { + defaultMessage: 'Counter rate of {name}', + values: { + name: + name ?? + i18n.translate('xpack.lens.indexPattern.incompleteOperation', { + defaultMessage: '(incomplete)', + }), + }, + }); +}; + +export type CounterRateIndexPatternColumn = FormattedIndexPatternColumn & + ReferenceBasedIndexPatternColumn & { + operationType: 'counter_rate'; + }; + +export const counterRateOperation: OperationDefinition< + CounterRateIndexPatternColumn, + 'fullReference' +> = { + type: 'counter_rate', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.counterRate', { + defaultMessage: 'Counter rate', + }), + input: 'fullReference', + selectionStyle: 'field', + requiredReferences: [ + { + input: ['field'], + specificOperations: ['max'], + validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed, + }, + ], + getPossibleOperation: () => { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + getDefaultLabel: (column, indexPattern, columns) => { + return ofName(columns[column.references[0]]?.label); + }, + toExpression: (layer, columnId) => { + return dateBasedOperationToExpression(layer, columnId, 'lens_counter_rate'); + }, + buildColumn: ({ referenceIds, previousColumn, layer }) => { + const metric = layer.columns[referenceIds[0]]; + return { + label: ofName(metric?.label), + dataType: 'number', + operationType: 'counter_rate', + isBucketed: false, + scale: 'ratio', + references: referenceIds, + params: + previousColumn?.dataType === 'number' && + previousColumn.params && + 'format' in previousColumn.params && + previousColumn.params.format + ? { format: previousColumn.params.format } + : undefined, + }; + }, + isTransferable: (column, newIndexPattern) => { + return hasDateField(newIndexPattern); + }, + getErrorMessage: (layer: IndexPatternLayer) => { + return checkForDateHistogram( + layer, + i18n.translate('xpack.lens.indexPattern.counterRate', { + defaultMessage: 'Counter rate', + }) + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx new file mode 100644 index 000000000000..9244aaaf90ab --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx @@ -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 { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types'; +import { IndexPatternLayer } from '../../../types'; +import { checkForDateHistogram, dateBasedOperationToExpression } from './utils'; +import { OperationDefinition } from '..'; + +const ofName = (name?: string) => { + return i18n.translate('xpack.lens.indexPattern.cumulativeSumOf', { + defaultMessage: 'Cumulative sum rate of {name}', + values: { + name: + name ?? + i18n.translate('xpack.lens.indexPattern.incompleteOperation', { + defaultMessage: '(incomplete)', + }), + }, + }); +}; + +export type CumulativeSumIndexPatternColumn = FormattedIndexPatternColumn & + ReferenceBasedIndexPatternColumn & { + operationType: 'cumulative_sum'; + }; + +export const cumulativeSumOperation: OperationDefinition< + CumulativeSumIndexPatternColumn, + 'fullReference' +> = { + type: 'cumulative_sum', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.cumulativeSum', { + defaultMessage: 'Cumulative sum', + }), + input: 'fullReference', + selectionStyle: 'field', + requiredReferences: [ + { + input: ['field'], + specificOperations: ['count', 'sum'], + validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed, + }, + ], + getPossibleOperation: () => { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + getDefaultLabel: (column, indexPattern, columns) => { + return ofName(columns[column.references[0]]?.label); + }, + toExpression: (layer, columnId) => { + return dateBasedOperationToExpression(layer, columnId, 'cumulative_sum'); + }, + buildColumn: ({ referenceIds, previousColumn, layer }) => { + const metric = layer.columns[referenceIds[0]]; + return { + label: ofName(metric?.label), + dataType: 'number', + operationType: 'cumulative_sum', + isBucketed: false, + scale: 'ratio', + references: referenceIds, + params: + previousColumn?.dataType === 'number' && + previousColumn.params && + 'format' in previousColumn.params && + previousColumn.params.format + ? { format: previousColumn.params.format } + : undefined, + }; + }, + isTransferable: () => { + return true; + }, + getErrorMessage: (layer: IndexPatternLayer) => { + return checkForDateHistogram( + layer, + i18n.translate('xpack.lens.indexPattern.cumulativeSum', { + defaultMessage: 'Cumulative sum', + }) + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx new file mode 100644 index 000000000000..7398f7e07ea4 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/derivative.tsx @@ -0,0 +1,90 @@ +/* + * 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 { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types'; +import { IndexPatternLayer } from '../../../types'; +import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils'; +import { OperationDefinition } from '..'; + +const ofName = (name?: string) => { + return i18n.translate('xpack.lens.indexPattern.derivativeOf', { + defaultMessage: 'Differences of {name}', + values: { + name: + name ?? + i18n.translate('xpack.lens.indexPattern.incompleteOperation', { + defaultMessage: '(incomplete)', + }), + }, + }); +}; + +export type DerivativeIndexPatternColumn = FormattedIndexPatternColumn & + ReferenceBasedIndexPatternColumn & { + operationType: 'derivative'; + }; + +export const derivativeOperation: OperationDefinition< + DerivativeIndexPatternColumn, + 'fullReference' +> = { + type: 'derivative', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.derivative', { + defaultMessage: 'Differences', + }), + input: 'fullReference', + selectionStyle: 'full', + requiredReferences: [ + { + input: ['field'], + validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed, + }, + ], + getPossibleOperation: () => { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + getDefaultLabel: (column, indexPattern, columns) => { + return ofName(columns[column.references[0]]?.label); + }, + toExpression: (layer, columnId) => { + return dateBasedOperationToExpression(layer, columnId, 'derivative'); + }, + buildColumn: ({ referenceIds, previousColumn, layer }) => { + const metric = layer.columns[referenceIds[0]]; + return { + label: ofName(metric?.label), + dataType: 'number', + operationType: 'derivative', + isBucketed: false, + scale: 'ratio', + references: referenceIds, + params: + previousColumn?.dataType === 'number' && + previousColumn.params && + 'format' in previousColumn.params && + previousColumn.params.format + ? { format: previousColumn.params.format } + : undefined, + }; + }, + isTransferable: (column, newIndexPattern) => { + return hasDateField(newIndexPattern); + }, + getErrorMessage: (layer: IndexPatternLayer) => { + return checkForDateHistogram( + layer, + i18n.translate('xpack.lens.indexPattern.derivative', { + defaultMessage: 'Differences', + }) + ); + }, +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts new file mode 100644 index 000000000000..30e87aef46a0 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { counterRateOperation, CounterRateIndexPatternColumn } from './counter_rate'; +export { cumulativeSumOperation, CumulativeSumIndexPatternColumn } from './cumulative_sum'; +export { derivativeOperation, DerivativeIndexPatternColumn } from './derivative'; +export { movingAverageOperation, MovingAverageIndexPatternColumn } from './moving_average'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx new file mode 100644 index 000000000000..795281d0fd99 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -0,0 +1,147 @@ +/* + * 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 { useState } from 'react'; +import React from 'react'; +import { EuiFormRow } from '@elastic/eui'; +import { EuiFieldNumber } from '@elastic/eui'; +import { FormattedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from '../column_types'; +import { IndexPatternLayer } from '../../../types'; +import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField } from './utils'; +import { updateColumnParam } from '../../layer_helpers'; +import { useDebounceWithOptions } from '../helpers'; +import type { OperationDefinition, ParamEditorProps } from '..'; + +const ofName = (name?: string) => { + return i18n.translate('xpack.lens.indexPattern.movingAverageOf', { + defaultMessage: 'Moving average of {name}', + values: { + name: + name ?? + i18n.translate('xpack.lens.indexPattern.incompleteOperation', { + defaultMessage: '(incomplete)', + }), + }, + }); +}; + +export type MovingAverageIndexPatternColumn = FormattedIndexPatternColumn & + ReferenceBasedIndexPatternColumn & { + operationType: 'moving_average'; + params: { + window: number; + }; + }; + +export const movingAverageOperation: OperationDefinition< + MovingAverageIndexPatternColumn, + 'fullReference' +> = { + type: 'moving_average', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.movingAverage', { + defaultMessage: 'Moving Average', + }), + input: 'fullReference', + selectionStyle: 'full', + requiredReferences: [ + { + input: ['field'], + validateMetadata: (meta) => meta.dataType === 'number' && !meta.isBucketed, + }, + ], + getPossibleOperation: () => { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + getDefaultLabel: (column, indexPattern, columns) => { + return ofName(columns[column.references[0]]?.label); + }, + toExpression: (layer, columnId) => { + return dateBasedOperationToExpression(layer, columnId, 'moving_average', { + window: [(layer.columns[columnId] as MovingAverageIndexPatternColumn).params.window], + }); + }, + buildColumn: ({ referenceIds, previousColumn, layer }) => { + const metric = layer.columns[referenceIds[0]]; + return { + label: ofName(metric?.label), + dataType: 'number', + operationType: 'moving_average', + isBucketed: false, + scale: 'ratio', + references: referenceIds, + params: + previousColumn?.dataType === 'number' && + previousColumn.params && + 'format' in previousColumn.params && + previousColumn.params.format + ? { format: previousColumn.params.format, window: 5 } + : { window: 5 }, + }; + }, + paramEditor: MovingAverageParamEditor, + isTransferable: (column, newIndexPattern) => { + return hasDateField(newIndexPattern); + }, + getErrorMessage: (layer: IndexPatternLayer) => { + return checkForDateHistogram( + layer, + i18n.translate('xpack.lens.indexPattern.movingAverage', { + defaultMessage: 'Moving Average', + }) + ); + }, +}; + +function MovingAverageParamEditor({ + state, + setState, + currentColumn, + layerId, +}: ParamEditorProps) { + const [inputValue, setInputValue] = useState(String(currentColumn.params.window)); + + useDebounceWithOptions( + () => { + if (inputValue === '') { + return; + } + const inputNumber = Number(inputValue); + setState( + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'window', + value: inputNumber, + }) + ); + }, + { skipFirstRender: true }, + 256, + [inputValue] + ); + return ( + + ) => setInputValue(e.target.value)} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts new file mode 100644 index 000000000000..c64a29228060 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionAST } from '@kbn/interpreter/common'; +import { IndexPattern, IndexPatternLayer } from '../../../types'; +import { ReferenceBasedIndexPatternColumn } from '../column_types'; + +/** + * Checks whether the current layer includes a date histogram and returns an error otherwise + */ +export function checkForDateHistogram(layer: IndexPatternLayer, name: string) { + const buckets = layer.columnOrder.filter((colId) => layer.columns[colId].isBucketed); + const hasDateHistogram = buckets.some( + (colId) => layer.columns[colId].operationType === 'date_histogram' + ); + if (hasDateHistogram) { + return undefined; + } + return [ + i18n.translate('xpack.lens.indexPattern.calculations.dateHistogramErrorMessage', { + defaultMessage: + '{name} requires a date histogram to work. Choose a different function or add a date histogram.', + values: { + name, + }, + }), + ]; +} + +export function hasDateField(indexPattern: IndexPattern) { + return indexPattern.fields.some((field) => field.type === 'date'); +} + +/** + * Creates an expression ast for a date based operation (cumulative sum, derivative, moving average, counter rate) + */ +export function dateBasedOperationToExpression( + layer: IndexPatternLayer, + columnId: string, + functionName: string, + additionalArgs: Record = {} +): ExpressionFunctionAST[] { + const currentColumn = (layer.columns[columnId] as unknown) as ReferenceBasedIndexPatternColumn; + const buckets = layer.columnOrder.filter((colId) => layer.columns[colId].isBucketed); + const dateColumnIndex = buckets.findIndex( + (colId) => layer.columns[colId].operationType === 'date_histogram' + )!; + buckets.splice(dateColumnIndex, 1); + + return [ + { + type: 'function', + function: functionName, + arguments: { + by: buckets, + inputColumnId: [currentColumn.references[0]], + outputColumnId: [columnId], + outputColumnName: [currentColumn.label], + ...additionalArgs, + }, + }, + ]; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts index 13bddc0c2ec2..aef9bb7731d4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts @@ -16,7 +16,7 @@ export interface BaseIndexPatternColumn extends Operation { // export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn { export type FormattedIndexPatternColumn = BaseIndexPatternColumn & { params?: { - format: { + format?: { id: string; params?: { decimals: number; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 0e7e125944e7..392377234d76 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -23,6 +23,16 @@ import { MedianIndexPatternColumn, } from './metrics'; import { dateHistogramOperation, DateHistogramIndexPatternColumn } from './date_histogram'; +import { + cumulativeSumOperation, + CumulativeSumIndexPatternColumn, + counterRateOperation, + CounterRateIndexPatternColumn, + derivativeOperation, + DerivativeIndexPatternColumn, + movingAverageOperation, + MovingAverageIndexPatternColumn, +} from './calculations'; import { countOperation, CountIndexPatternColumn } from './count'; import { StateSetter, OperationMetadata } from '../../../types'; import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; @@ -52,7 +62,11 @@ export type IndexPatternColumn = | CardinalityIndexPatternColumn | SumIndexPatternColumn | MedianIndexPatternColumn - | CountIndexPatternColumn; + | CountIndexPatternColumn + | CumulativeSumIndexPatternColumn + | CounterRateIndexPatternColumn + | DerivativeIndexPatternColumn + | MovingAverageIndexPatternColumn; export type FieldBasedIndexPatternColumn = Extract; @@ -73,6 +87,10 @@ const internalOperationDefinitions = [ medianOperation, countOperation, rangeOperation, + cumulativeSumOperation, + counterRateOperation, + derivativeOperation, + movingAverageOperation, ]; export { termsOperation } from './terms'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 1495a876a2c8..58a066c81a1a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -424,7 +424,6 @@ export function deleteColumn({ }; } - // @ts-expect-error this fails statically because there are no references added const extraDeletions: string[] = 'references' in column ? column.references : []; const hypotheticalColumns = { ...layer.columns }; @@ -452,11 +451,9 @@ export function getColumnOrder(layer: IndexPatternLayer): string[] { ); // If a reference has another reference as input, put it last in sort order referenceBased.sort(([idA, a], [idB, b]) => { - // @ts-expect-error not statically analyzed if ('references' in a && a.references.includes(idB)) { return 1; } - // @ts-expect-error not statically analyzed if ('references' in b && b.references.includes(idA)) { return -1; } @@ -517,14 +514,12 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined } if ('references' in column) { - // @ts-expect-error references are not statically analyzed yet column.references.forEach((referenceId, index) => { if (!layer.columns[referenceId]) { errors.push( i18n.translate('xpack.lens.indexPattern.missingReferenceError', { defaultMessage: 'Dimension {dimensionLabel} is incomplete', values: { - // @ts-expect-error references are not statically analyzed yet dimensionLabel: column.label, }, }) @@ -544,7 +539,6 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined i18n.translate('xpack.lens.indexPattern.invalidReferenceConfiguration', { defaultMessage: 'Dimension {dimensionLabel} does not have a valid configuration', values: { - // @ts-expect-error references are not statically analyzed yet dimensionLabel: column.label, }, }) @@ -560,10 +554,7 @@ export function getErrorMessages(layer: IndexPatternLayer): string[] | undefined export function isReferenced(layer: IndexPatternLayer, columnId: string): boolean { const allReferences = Object.values(layer.columns).flatMap((col) => - 'references' in col - ? // @ts-expect-error not statically analyzed - col.references - : [] + 'references' in col ? col.references : [] ); return allReferences.includes(columnId); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index d6f5b10cf64e..63d0fd3d4e5c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -247,6 +247,22 @@ describe('getOperationTypesForField', () => { "operationType": "sum", "type": "field", }, + Object { + "operationType": "cumulative_sum", + "type": "fullReference", + }, + Object { + "operationType": "counter_rate", + "type": "fullReference", + }, + Object { + "operationType": "derivative", + "type": "fullReference", + }, + Object { + "operationType": "moving_average", + "type": "fullReference", + }, Object { "field": "bytes", "operationType": "min", From 6cd4d8465703807aac16db38e0783eb46037da78 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Wed, 25 Nov 2020 10:29:08 +0100 Subject: [PATCH 54/89] [Lens] Fix Treemap outer labels with transparent background (#84245) --- .../lens/public/pie_visualization/render_function.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index 20d558fefc3d..56ecf57f2dff 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -68,6 +68,7 @@ export function PieComponent( } = props.args; const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); + const isDarkMode = chartsThemeService.useDarkMode(); if (!hideLabels) { firstTable.columns.forEach((column) => { @@ -128,7 +129,9 @@ export function PieComponent( if (shape === 'treemap') { // Only highlight the innermost color of the treemap, as it accurately represents area if (layerIndex < bucketColumns.length - 1) { - return 'rgba(0,0,0,0)'; + // Mind the difference here: the contrast computation for the text ignores the alpha/opacity + // therefore change it for dask mode + return isDarkMode ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)'; } // only use the top level series layer for coloring if (seriesLayers.length > 1) { @@ -263,6 +266,7 @@ export function PieComponent( theme={{ ...chartTheme, background: { + ...chartTheme.background, color: undefined, // removes background for embeddables }, }} From 4aa1683b3b95a8f4842a82e38a3ac7306fdd7a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Wed, 25 Nov 2020 10:45:32 +0100 Subject: [PATCH 55/89] [Security Solution] Cleanup graphiql (#82595) --- package.json | 1 - .../server/lib/compose/kibana.ts | 3 +- .../lib/framework/kibana_framework_adapter.ts | 32 +---------- .../security_solution/server/plugin.ts | 2 +- .../security_solution/feature_controls.ts | 53 ------------------- 5 files changed, 3 insertions(+), 88 deletions(-) diff --git a/package.json b/package.json index ab1cb90c900a..1febfc2380b7 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,6 @@ "apollo-server-core": "^1.3.6", "apollo-server-errors": "^2.0.2", "apollo-server-hapi": "^1.3.6", - "apollo-server-module-graphiql": "^1.3.4", "archiver": "^3.1.1", "axios": "^0.19.2", "bluebird": "3.5.5", diff --git a/x-pack/plugins/security_solution/server/lib/compose/kibana.ts b/x-pack/plugins/security_solution/server/lib/compose/kibana.ts index 433ee4a5f99f..b680b19f3181 100644 --- a/x-pack/plugins/security_solution/server/lib/compose/kibana.ts +++ b/x-pack/plugins/security_solution/server/lib/compose/kibana.ts @@ -23,10 +23,9 @@ import { EndpointAppContext } from '../../endpoint/types'; export function compose( core: CoreSetup, plugins: SetupPlugins, - isProductionMode: boolean, endpointContext: EndpointAppContext ): AppBackendLibs { - const framework = new KibanaBackendFrameworkAdapter(core, plugins, isProductionMode); + const framework = new KibanaBackendFrameworkAdapter(core, plugins); const sources = new Sources(new ConfigurationSourcesAdapter()); const sourceStatus = new SourceStatus(new ElasticsearchSourceStatusAdapter(framework)); diff --git a/x-pack/plugins/security_solution/server/lib/framework/kibana_framework_adapter.ts b/x-pack/plugins/security_solution/server/lib/framework/kibana_framework_adapter.ts index e36fb1144e93..8327af846d1a 100644 --- a/x-pack/plugins/security_solution/server/lib/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/security_solution/server/lib/framework/kibana_framework_adapter.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as GraphiQL from 'apollo-server-module-graphiql'; import { GraphQLSchema } from 'graphql'; import { runHttpQuery } from 'apollo-server-core'; import { schema as configSchema } from '@kbn/config-schema'; @@ -31,7 +30,7 @@ export class KibanaBackendFrameworkAdapter implements FrameworkAdapter { private router: IRouter; private security: SetupPlugins['security']; - constructor(core: CoreSetup, plugins: SetupPlugins, private isProductionMode: boolean) { + constructor(core: CoreSetup, plugins: SetupPlugins) { this.router = core.http.createRouter(); this.security = plugins.security; } @@ -90,35 +89,6 @@ export class KibanaBackendFrameworkAdapter implements FrameworkAdapter { } } ); - - if (!this.isProductionMode) { - this.router.get( - { - path: `${routePath}/graphiql`, - validate: false, - options: { - tags: ['access:securitySolution'], - }, - }, - async (context, request, response) => { - const graphiqlString = await GraphiQL.resolveGraphiQLString( - request.query, - { - endpointURL: routePath, - passHeader: "'kbn-xsrf': 'graphiql'", - }, - request - ); - - return response.ok({ - body: graphiqlString, - headers: { - 'content-type': 'text/html', - }, - }); - } - ); - } } private async getCurrentUserInfo(request: KibanaRequest): Promise { diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index d963b3b093d8..088af40a84ae 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -290,7 +290,7 @@ export class Plugin implements IPlugin { diff --git a/x-pack/test/api_integration/apis/security_solution/feature_controls.ts b/x-pack/test/api_integration/apis/security_solution/feature_controls.ts index 0137a90ce981..9377c255f2d1 100644 --- a/x-pack/test/api_integration/apis/security_solution/feature_controls.ts +++ b/x-pack/test/api_integration/apis/security_solution/feature_controls.ts @@ -19,8 +19,6 @@ const introspectionQuery = gql` `; export default function ({ getService }: FtrProviderContext) { - const config = getService('config'); - const supertest = getService('supertestWithoutAuth'); const security = getService('security'); const spaces = getService('spaces'); const clientFactory = getService('securitySolutionGraphQLClientFactory'); @@ -38,18 +36,6 @@ export default function ({ getService }: FtrProviderContext) { expect(result.response.data).to.be.an('object'); }; - const expectGraphIQL404 = (result: any) => { - expect(result.error).to.be(undefined); - expect(result.response).not.to.be(undefined); - expect(result.response).to.have.property('statusCode', 404); - }; - - const expectGraphIQLResponse = (result: any) => { - expect(result.error).to.be(undefined); - expect(result.response).not.to.be(undefined); - expect(result.response).to.have.property('statusCode', 200); - }; - const executeGraphQLQuery = async (username: string, password: string, spaceId?: string) => { const queryOptions = { query: introspectionQuery, @@ -71,23 +57,7 @@ export default function ({ getService }: FtrProviderContext) { }; }; - const executeGraphIQLRequest = async (username: string, password: string, spaceId?: string) => { - const basePath = spaceId ? `/s/${spaceId}` : ''; - - return supertest - .get(`${basePath}/api/security_solution/graphql/graphiql`) - .auth(username, password) - .then((response: any) => ({ error: undefined, response })) - .catch((error: any) => ({ error, response: undefined })); - }; - describe('feature controls', () => { - let isProdOrCi = false; - before(() => { - const kbnConfig = config.get('servers.kibana'); - isProdOrCi = - !!process.env.CI || !(kbnConfig.hostname === 'localhost' && kbnConfig.port === 5620); - }); it(`APIs can't be accessed by user with no privileges`, async () => { const username = 'logstash_read'; const roleName = 'logstash_read'; @@ -103,9 +73,6 @@ export default function ({ getService }: FtrProviderContext) { const graphQLResult = await executeGraphQLQuery(username, password); expectGraphQL403(graphQLResult); - - const graphQLIResult = await executeGraphIQLRequest(username, password); - expectGraphIQL404(graphQLIResult); } finally { await security.role.delete(roleName); await security.user.delete(username); @@ -134,13 +101,6 @@ export default function ({ getService }: FtrProviderContext) { const graphQLResult = await executeGraphQLQuery(username, password); expectGraphQLResponse(graphQLResult); - - const graphQLIResult = await executeGraphIQLRequest(username, password); - if (!isProdOrCi) { - expectGraphIQLResponse(graphQLIResult); - } else { - expectGraphIQL404(graphQLIResult); - } } finally { await security.role.delete(roleName); await security.user.delete(username); @@ -172,9 +132,6 @@ export default function ({ getService }: FtrProviderContext) { const graphQLResult = await executeGraphQLQuery(username, password); expectGraphQL403(graphQLResult); - - const graphQLIResult = await executeGraphIQLRequest(username, password); - expectGraphIQL404(graphQLIResult); } finally { await security.role.delete(roleName); await security.user.delete(username); @@ -233,21 +190,11 @@ export default function ({ getService }: FtrProviderContext) { it('user_1 can access APIs in space_1', async () => { const graphQLResult = await executeGraphQLQuery(username, password, space1Id); expectGraphQLResponse(graphQLResult); - - const graphQLIResult = await executeGraphIQLRequest(username, password, space1Id); - if (!isProdOrCi) { - expectGraphIQLResponse(graphQLIResult); - } else { - expectGraphIQL404(graphQLIResult); - } }); it(`user_1 can't access APIs in space_2`, async () => { const graphQLResult = await executeGraphQLQuery(username, password, space2Id); expectGraphQL403(graphQLResult); - - const graphQLIResult = await executeGraphIQLRequest(username, password, space2Id); - expectGraphIQL404(graphQLIResult); }); }); }); From f294a9e2abb896fcb37e8fdf815c46fc69b3965e Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Wed, 25 Nov 2020 11:07:08 +0100 Subject: [PATCH 56/89] [Discover] Refactor discover.js controller topnav code (#79062) * Move discover.js functions to helper functions in separate files * Convert to TypeScript * Add unit tests * Add removeField function to SearchSource --- ...plugin-plugins-data-public.searchsource.md | 1 + ...ns-data-public.searchsource.removefield.md | 24 + .../data/common/search/search_source/mocks.ts | 1 + .../search_source/search_source.test.ts | 9 + .../search/search_source/search_source.ts | 14 +- src/plugins/data/public/public.api.md | 1 + .../discover/public/__mocks__/config.ts | 30 ++ .../public/__mocks__/index_pattern.ts | 74 +++ .../public/__mocks__/index_patterns.ts | 32 ++ .../discover/public/__mocks__/saved_search.ts | 41 ++ .../public/application/angular/discover.js | 510 +++--------------- .../angular/discover_state.test.ts | 4 +- .../application/angular/discover_state.ts | 15 +- ...s.snap => open_search_panel.test.tsx.snap} | 4 +- .../top_nav/get_top_nav_links.test.ts | 85 +++ .../components/top_nav/get_top_nav_links.ts | 148 +++++ .../top_nav/on_save_search.test.tsx | 47 ++ .../components/top_nav/on_save_search.tsx | 158 ++++++ ...nel.test.js => open_search_panel.test.tsx} | 4 +- ..._search_panel.js => open_search_panel.tsx} | 14 +- ...ch_panel.js => show_open_search_panel.tsx} | 9 +- .../public/application/helpers/breadcrumbs.ts | 28 + .../helpers/calc_field_counts.test.ts | 58 ++ .../application/helpers/calc_field_counts.ts | 38 ++ .../helpers/get_index_pattern_id.ts | 60 --- .../helpers/get_sharing_data.test.ts | 70 +++ .../application/helpers/get_sharing_data.ts | 88 +++ .../helpers/persist_saved_search.ts | 65 +++ .../helpers/resolve_index_pattern.test.ts | 56 ++ .../helpers/resolve_index_pattern.ts | 158 ++++++ .../helpers/update_search_source.test.ts | 51 ++ .../helpers/update_search_source.ts | 54 ++ .../discover/public/saved_searches/types.ts | 7 +- 33 files changed, 1449 insertions(+), 509 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.removefield.md create mode 100644 src/plugins/discover/public/__mocks__/config.ts create mode 100644 src/plugins/discover/public/__mocks__/index_pattern.ts create mode 100644 src/plugins/discover/public/__mocks__/index_patterns.ts create mode 100644 src/plugins/discover/public/__mocks__/saved_search.ts rename src/plugins/discover/public/application/components/top_nav/__snapshots__/{open_search_panel.test.js.snap => open_search_panel.test.tsx.snap} (96%) create mode 100644 src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts create mode 100644 src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts create mode 100644 src/plugins/discover/public/application/components/top_nav/on_save_search.test.tsx create mode 100644 src/plugins/discover/public/application/components/top_nav/on_save_search.tsx rename src/plugins/discover/public/application/components/top_nav/{open_search_panel.test.js => open_search_panel.test.tsx} (89%) rename src/plugins/discover/public/application/components/top_nav/{open_search_panel.js => open_search_panel.tsx} (94%) rename src/plugins/discover/public/application/components/top_nav/{show_open_search_panel.js => show_open_search_panel.tsx} (87%) create mode 100644 src/plugins/discover/public/application/helpers/calc_field_counts.test.ts create mode 100644 src/plugins/discover/public/application/helpers/calc_field_counts.ts delete mode 100644 src/plugins/discover/public/application/helpers/get_index_pattern_id.ts create mode 100644 src/plugins/discover/public/application/helpers/get_sharing_data.test.ts create mode 100644 src/plugins/discover/public/application/helpers/get_sharing_data.ts create mode 100644 src/plugins/discover/public/application/helpers/persist_saved_search.ts create mode 100644 src/plugins/discover/public/application/helpers/resolve_index_pattern.test.ts create mode 100644 src/plugins/discover/public/application/helpers/resolve_index_pattern.ts create mode 100644 src/plugins/discover/public/application/helpers/update_search_source.test.ts create mode 100644 src/plugins/discover/public/application/helpers/update_search_source.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md index 548fa66e6e51..df302e9f3b0d 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md @@ -41,6 +41,7 @@ export declare class SearchSource | [getSearchRequestBody()](./kibana-plugin-plugins-data-public.searchsource.getsearchrequestbody.md) | | Returns body contents of the search request, often referred as query DSL. | | [getSerializedFields()](./kibana-plugin-plugins-data-public.searchsource.getserializedfields.md) | | serializes search source fields (which can later be passed to [ISearchStartSearchSource](./kibana-plugin-plugins-data-public.isearchstartsearchsource.md)) | | [onRequestStart(handler)](./kibana-plugin-plugins-data-public.searchsource.onrequeststart.md) | | Add a handler that will be notified whenever requests start | +| [removeField(field)](./kibana-plugin-plugins-data-public.searchsource.removefield.md) | | remove field | | [serialize()](./kibana-plugin-plugins-data-public.searchsource.serialize.md) | | Serializes the instance to a JSON string and a set of referenced objects. Use this method to get a representation of the search source which can be stored in a saved object.The references returned by this function can be mixed with other references in the same object, however make sure there are no name-collisions. The references will be named kibanaSavedObjectMeta.searchSourceJSON.index and kibanaSavedObjectMeta.searchSourceJSON.filter[<number>].meta.index.Using createSearchSource, the instance can be re-created. | | [setField(field, value)](./kibana-plugin-plugins-data-public.searchsource.setfield.md) | | sets value to a single search source field | | [setFields(newFields)](./kibana-plugin-plugins-data-public.searchsource.setfields.md) | | Internal, do not use. Overrides all search source fields with the new field array. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.removefield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.removefield.md new file mode 100644 index 000000000000..1e6b63be997f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.removefield.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) > [removeField](./kibana-plugin-plugins-data-public.searchsource.removefield.md) + +## SearchSource.removeField() method + +remove field + +Signature: + +```typescript +removeField(field: K): this; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| field | K | | + +Returns: + +`this` + diff --git a/src/plugins/data/common/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts index ea7d6b4441cc..dd2b0eaccc86 100644 --- a/src/plugins/data/common/search/search_source/mocks.ts +++ b/src/plugins/data/common/search/search_source/mocks.ts @@ -28,6 +28,7 @@ export const searchSourceInstanceMock: MockedKeys = { setPreferredSearchStrategyId: jest.fn(), setFields: jest.fn().mockReturnThis(), setField: jest.fn().mockReturnThis(), + removeField: jest.fn().mockReturnThis(), getId: jest.fn(), getFields: jest.fn(), getField: jest.fn(), diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 98d66310c040..e7bdcb159f3c 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -82,6 +82,15 @@ describe('SearchSource', () => { }); }); + describe('#removeField()', () => { + test('remove property', () => { + const searchSource = new SearchSource({}, searchSourceDependencies); + searchSource.setField('aggs', 5); + searchSource.removeField('aggs'); + expect(searchSource.getField('aggs')).toBeFalsy(); + }); + }); + describe(`#setField('index')`, () => { describe('auto-sourceFiltering', () => { describe('new index pattern assigned', () => { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 9bc65ca34198..79ef3a3f11ca 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -142,10 +142,18 @@ export class SearchSource { */ setField(field: K, value: SearchSourceFields[K]) { if (value == null) { - delete this.fields[field]; - } else { - this.fields[field] = value; + return this.removeField(field); } + this.fields[field] = value; + return this; + } + + /** + * remove field + * @param field: field name + */ + removeField(field: K) { + delete this.fields[field]; return this; } diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 12d6fc5ad32c..7c4d3ee27cf4 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2169,6 +2169,7 @@ export class SearchSource { // (undocumented) history: SearchRequest[]; onRequestStart(handler: (searchSource: SearchSource, options?: ISearchOptions) => Promise): void; + removeField(field: K): this; serialize(): { searchSourceJSON: string; references: import("src/core/server").SavedObjectReference[]; diff --git a/src/plugins/discover/public/__mocks__/config.ts b/src/plugins/discover/public/__mocks__/config.ts new file mode 100644 index 000000000000..a6cdfedd795b --- /dev/null +++ b/src/plugins/discover/public/__mocks__/config.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IUiSettingsClient } from '../../../../core/public'; + +export const configMock = ({ + get: (key: string) => { + if (key === 'defaultIndex') { + return 'the-index-pattern-id'; + } + + return ''; + }, +} as unknown) as IUiSettingsClient; diff --git a/src/plugins/discover/public/__mocks__/index_pattern.ts b/src/plugins/discover/public/__mocks__/index_pattern.ts new file mode 100644 index 000000000000..696079ec72a7 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/index_pattern.ts @@ -0,0 +1,74 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IndexPattern, indexPatterns } from '../kibana_services'; +import { IIndexPatternFieldList } from '../../../data/common/index_patterns/fields'; + +const fields = [ + { + name: '_index', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'message', + type: 'string', + scripted: false, + filterable: false, + }, + { + name: 'extension', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'bytes', + type: 'number', + scripted: false, + filterable: true, + }, + { + name: 'scripted', + type: 'number', + scripted: true, + filterable: false, + }, +] as IIndexPatternFieldList; + +fields.getByName = (name: string) => { + return fields.find((field) => field.name === name); +}; + +const indexPattern = ({ + id: 'the-index-pattern-id', + title: 'the-index-pattern-title', + metaFields: ['_index', '_score'], + flattenHit: undefined, + formatHit: jest.fn((hit) => hit._source), + fields, + getComputedFields: () => ({}), + getSourceFiltering: () => ({}), + getFieldByName: () => ({}), +} as unknown) as IndexPattern; + +indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields); + +export const indexPatternMock = indexPattern; diff --git a/src/plugins/discover/public/__mocks__/index_patterns.ts b/src/plugins/discover/public/__mocks__/index_patterns.ts new file mode 100644 index 000000000000..f413a111a1d7 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/index_patterns.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IndexPatternsService } from '../../../data/common'; +import { indexPatternMock } from './index_pattern'; + +export const indexPatternsMock = ({ + getCache: () => { + return [indexPatternMock]; + }, + get: (id: string) => { + if (id === 'the-index-pattern-id') { + return indexPatternMock; + } + }, +} as unknown) as IndexPatternsService; diff --git a/src/plugins/discover/public/__mocks__/saved_search.ts b/src/plugins/discover/public/__mocks__/saved_search.ts new file mode 100644 index 000000000000..11f36fdfde67 --- /dev/null +++ b/src/plugins/discover/public/__mocks__/saved_search.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedSearch } from '../saved_searches'; + +export const savedSearchMock = ({ + id: 'the-saved-search-id', + type: 'search', + attributes: { + title: 'the-saved-search-title', + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"highlightAll":true,"version":true,"query":{"query":"foo : \\"bar\\" ","language":"kuery"},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', + }, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: 'the-index-pattern-id', + }, + ], + migrationVersion: { search: '7.5.0' }, + error: undefined, +} as unknown) as SavedSearch; diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 9319c58db3e3..272c2f2ca618 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -18,8 +18,7 @@ */ import _ from 'lodash'; -import React from 'react'; -import { Subscription, Subject, merge } from 'rxjs'; +import { merge, Subject, Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import moment from 'moment'; import dateMath from '@elastic/datemath'; @@ -28,31 +27,52 @@ import { getState, splitState } from './discover_state'; import { RequestAdapter } from '../../../../inspector/public'; import { + connectToQueryState, esFilters, indexPatterns as indexPatternsUtils, - connectToQueryState, syncQueryStateWithUrl, } from '../../../../data/public'; -import { SavedObjectSaveModal, showSaveModal } from '../../../../saved_objects/public'; -import { getSortArray, getSortForSearchSource } from './doc_table'; +import { getSortArray } from './doc_table'; import { createFixedScroll } from './directives/fixed_scroll'; import * as columnActions from './doc_table/actions/columns'; import indexTemplateLegacy from './discover_legacy.html'; -import { showOpenSearchPanel } from '../components/top_nav/show_open_search_panel'; import { addHelpMenuToAppChrome } from '../components/help_menu/help_menu_util'; import { discoverResponseHandler } from './response_handler'; import { + getAngularModule, + getHeaderActionMenuMounter, getRequestInspectorStats, getResponseInspectorStats, getServices, - getHeaderActionMenuMounter, getUrlTracker, - unhashUrl, + redirectWhenMissing, subscribeWithScope, tabifyAggResponse, - getAngularModule, - redirectWhenMissing, } from '../../kibana_services'; +import { + getRootBreadcrumbs, + getSavedSearchBreadcrumbs, + setBreadcrumbsTitle, +} from '../helpers/breadcrumbs'; +import { validateTimeRange } from '../helpers/validate_time_range'; +import { popularizeField } from '../helpers/popularize_field'; +import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state'; +import { addFatalError } from '../../../../kibana_legacy/public'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator'; +import { removeQueryParam, getQueryParams } from '../../../../kibana_utils/public'; +import { + DEFAULT_COLUMNS_SETTING, + MODIFY_COLUMNS_ON_SWITCH, + SAMPLE_SIZE_SETTING, + SEARCH_ON_PAGE_LOAD_SETTING, +} from '../../../common'; +import { resolveIndexPattern, loadIndexPattern } from '../helpers/resolve_index_pattern'; +import { getTopNavLinks } from '../components/top_nav/get_top_nav_links'; +import { updateSearchSource } from '../helpers/update_search_source'; +import { calcFieldCounts } from '../helpers/calc_field_counts'; + +const services = getServices(); const { core, @@ -61,30 +81,11 @@ const { history: getHistory, indexPatterns, filterManager, - share, timefilter, toastNotifications, uiSettings: config, trackUiMetric, -} = getServices(); - -import { getRootBreadcrumbs, getSavedSearchBreadcrumbs } from '../helpers/breadcrumbs'; -import { validateTimeRange } from '../helpers/validate_time_range'; -import { popularizeField } from '../helpers/popularize_field'; -import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state'; -import { getIndexPatternId } from '../helpers/get_index_pattern_id'; -import { addFatalError } from '../../../../kibana_legacy/public'; -import { - DEFAULT_COLUMNS_SETTING, - SAMPLE_SIZE_SETTING, - SORT_DEFAULT_ORDER_SETTING, - SEARCH_ON_PAGE_LOAD_SETTING, - DOC_HIDE_TIME_COLUMN_SETTING, - MODIFY_COLUMNS_ON_SWITCH, -} from '../../../common'; -import { METRIC_TYPE } from '@kbn/analytics'; -import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator'; -import { removeQueryParam, getQueryParams } from '../../../../kibana_utils/public'; +} = services; const fetchStatuses = { UNINITIALIZED: 'uninitialized', @@ -132,24 +133,7 @@ app.config(($routeProvider) => { const { appStateContainer } = getState({ history }); const { index } = appStateContainer.getState(); return Promise.props({ - ip: indexPatterns.getCache().then((indexPatternList) => { - /** - * In making the indexPattern modifiable it was placed in appState. Unfortunately, - * the load order of AppState conflicts with the load order of many other things - * so in order to get the name of the index we should use, and to switch to the - * default if necessary, we parse the appState with a temporary State object and - * then destroy it immediatly after we're done - * - * @type {State} - */ - const id = getIndexPatternId(index, indexPatternList, config.get('defaultIndex')); - return Promise.props({ - list: indexPatternList, - loaded: indexPatterns.get(id), - stateVal: index, - stateValFound: !!index && id === index, - }); - }), + ip: loadIndexPattern(index, data.indexPatterns, config), savedSearch: getServices() .getSavedSearchById(savedSearchId) .then((savedSearch) => { @@ -204,7 +188,11 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise let inspectorRequest; const savedSearch = $route.current.locals.savedObjects.savedSearch; $scope.searchSource = savedSearch.searchSource; - $scope.indexPattern = resolveIndexPatternLoading(); + $scope.indexPattern = resolveIndexPattern( + $route.current.locals.savedObjects.ip, + $scope.searchSource, + toastNotifications + ); //used for functional testing $scope.fetchCounter = 0; @@ -216,22 +204,22 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise // used for restoring background session let isInitialSearch = true; + const state = getState({ + getStateDefaults, + storeInSessionStorage: config.get('state:storeInSessionStorage'), + history, + toasts: core.notifications.toasts, + }); const { appStateContainer, startSync: startStateSync, stopSync: stopStateSync, setAppState, replaceUrlAppState, - isAppStateDirty, kbnUrlStateStorage, getPreviousAppState, - resetInitialAppState, - } = getState({ - defaultAppState: getStateDefaults(), - storeInSessionStorage: config.get('state:storeInSessionStorage'), - history, - toasts: core.notifications.toasts, - }); + } = state; + if (appStateContainer.getState().index !== $scope.indexPattern.id) { //used index pattern is different than the given by url/state which is invalid setAppState({ index: $scope.indexPattern.id }); @@ -349,145 +337,36 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise unlistenHistoryBasePath(); }); - const getTopNavLinks = () => { - const newSearch = { - id: 'new', - label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { - defaultMessage: 'New', - }), - description: i18n.translate('discover.localMenu.newSearchDescription', { - defaultMessage: 'New Search', - }), - run: function () { - $scope.$evalAsync(() => { - history.push('/'); - }); - }, - testId: 'discoverNewButton', - }; - - const saveSearch = { - id: 'save', - label: i18n.translate('discover.localMenu.saveTitle', { - defaultMessage: 'Save', - }), - description: i18n.translate('discover.localMenu.saveSearchDescription', { - defaultMessage: 'Save Search', - }), - testId: 'discoverSaveButton', - run: async () => { - const onSave = ({ - newTitle, - newCopyOnSave, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }) => { - const currentTitle = savedSearch.title; - savedSearch.title = newTitle; - savedSearch.copyOnSave = newCopyOnSave; - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }; - return saveDataSource(saveOptions).then((response) => { - // If the save wasn't successful, put the original values back. - if (!response.id || response.error) { - savedSearch.title = currentTitle; - } else { - resetInitialAppState(); - } - return response; - }); - }; - - const saveModal = ( - {}} - title={savedSearch.title} - showCopyOnSave={!!savedSearch.id} - objectType="search" - description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { - defaultMessage: - 'Save your Discover search so you can use it in visualizations and dashboards', - })} - showDescription={false} - /> - ); - showSaveModal(saveModal, core.i18n.Context); - }, - }; - - const openSearch = { - id: 'open', - label: i18n.translate('discover.localMenu.openTitle', { - defaultMessage: 'Open', - }), - description: i18n.translate('discover.localMenu.openSavedSearchDescription', { - defaultMessage: 'Open Saved Search', - }), - testId: 'discoverOpenButton', - run: () => { - showOpenSearchPanel({ - makeUrl: (searchId) => `#/view/${encodeURIComponent(searchId)}`, - I18nContext: core.i18n.Context, - }); - }, - }; - - const shareSearch = { - id: 'share', - label: i18n.translate('discover.localMenu.shareTitle', { - defaultMessage: 'Share', - }), - description: i18n.translate('discover.localMenu.shareSearchDescription', { - defaultMessage: 'Share Search', - }), - testId: 'shareTopNavButton', - run: async (anchorElement) => { - const sharingData = await this.getSharingData(); - share.toggleShareContextMenu({ - anchorElement, - allowEmbed: false, - allowShortUrl: uiCapabilities.discover.createShortUrl, - shareableUrl: unhashUrl(window.location.href), - objectId: savedSearch.id, - objectType: 'search', - sharingData: { - ...sharingData, - title: savedSearch.title, - }, - isDirty: !savedSearch.id || isAppStateDirty(), - }); - }, - }; - - const inspectSearch = { - id: 'inspect', - label: i18n.translate('discover.localMenu.inspectTitle', { - defaultMessage: 'Inspect', - }), - description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', { - defaultMessage: 'Open Inspector for search', - }), - testId: 'openInspectorButton', - run() { - getServices().inspector.open(inspectorAdapters, { - title: savedSearch.title, - }); - }, - }; + const getFieldCounts = async () => { + // the field counts aren't set until we have the data back, + // so we wait for the fetch to be done before proceeding + if ($scope.fetchStatus === fetchStatuses.COMPLETE) { + return $scope.fieldCounts; + } - return [ - newSearch, - ...(uiCapabilities.discover.save ? [saveSearch] : []), - openSearch, - shareSearch, - inspectSearch, - ]; + return await new Promise((resolve) => { + const unwatch = $scope.$watch('fetchStatus', (newValue) => { + if (newValue === fetchStatuses.COMPLETE) { + unwatch(); + resolve($scope.fieldCounts); + } + }); + }); }; - $scope.topNavMenu = getTopNavLinks(); + + $scope.topNavMenu = getTopNavLinks({ + getFieldCounts, + indexPattern: $scope.indexPattern, + inspectorAdapters, + navigateTo: (path) => { + $scope.$evalAsync(() => { + history.push(path); + }); + }, + savedSearch, + services, + state, + }); $scope.searchSource .setField('index', $scope.indexPattern) @@ -511,96 +390,8 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : ''; chrome.docTitle.change(`Discover${pageTitleSuffix}`); - const discoverBreadcrumbsTitle = i18n.translate('discover.discoverBreadcrumbTitle', { - defaultMessage: 'Discover', - }); - - if (savedSearch.id && savedSearch.title) { - chrome.setBreadcrumbs([ - { - text: discoverBreadcrumbsTitle, - href: '#/', - }, - { text: savedSearch.title }, - ]); - } else { - chrome.setBreadcrumbs([ - { - text: discoverBreadcrumbsTitle, - }, - ]); - } - const getFieldCounts = async () => { - // the field counts aren't set until we have the data back, - // so we wait for the fetch to be done before proceeding - if ($scope.fetchStatus === fetchStatuses.COMPLETE) { - return $scope.fieldCounts; - } - - return await new Promise((resolve) => { - const unwatch = $scope.$watch('fetchStatus', (newValue) => { - if (newValue === fetchStatuses.COMPLETE) { - unwatch(); - resolve($scope.fieldCounts); - } - }); - }); - }; - - const getSharingDataFields = async (selectedFields, timeFieldName, hideTimeColumn) => { - if (selectedFields.length === 1 && selectedFields[0] === '_source') { - const fieldCounts = await getFieldCounts(); - return { - searchFields: null, - selectFields: _.keys(fieldCounts).sort(), - }; - } - - const fields = - timeFieldName && !hideTimeColumn ? [timeFieldName, ...selectedFields] : selectedFields; - return { - searchFields: fields, - selectFields: fields, - }; - }; - - this.getSharingData = async () => { - const searchSource = $scope.searchSource.createCopy(); - - const { searchFields, selectFields } = await getSharingDataFields( - $scope.state.columns, - $scope.indexPattern.timeFieldName, - config.get(DOC_HIDE_TIME_COLUMN_SETTING) - ); - searchSource.setField('fields', searchFields); - searchSource.setField( - 'sort', - getSortForSearchSource( - $scope.state.sort, - $scope.indexPattern, - config.get(SORT_DEFAULT_ORDER_SETTING) - ) - ); - searchSource.setField('highlight', null); - searchSource.setField('highlightAll', null); - searchSource.setField('aggs', null); - searchSource.setField('size', null); - - const body = await searchSource.getSearchRequestBody(); - return { - searchRequest: { - index: searchSource.getField('index').title, - body, - }, - fields: selectFields, - metaFields: $scope.indexPattern.metaFields, - conflictedTypesFields: $scope.indexPattern.fields - .filter((f) => f.type === 'conflict') - .map((f) => f.name), - indexPatternId: searchSource.getField('index').id, - }; - }; + setBreadcrumbsTitle(savedSearch, chrome); function getStateDefaults() { const query = $scope.searchSource.getField('query') || data.query.queryString.getDefaultQuery(); @@ -739,57 +530,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }); }); - async function saveDataSource(saveOptions) { - await $scope.updateDataSource(); - - savedSearch.columns = $scope.state.columns; - savedSearch.sort = $scope.state.sort; - - try { - const id = await savedSearch.save(saveOptions); - $scope.$evalAsync(() => { - if (id) { - toastNotifications.addSuccess({ - title: i18n.translate('discover.notifications.savedSearchTitle', { - defaultMessage: `Search '{savedSearchTitle}' was saved`, - values: { - savedSearchTitle: savedSearch.title, - }, - }), - 'data-test-subj': 'saveSearchSuccess', - }); - - if (savedSearch.id !== $route.current.params.id) { - history.push(`/view/${encodeURIComponent(savedSearch.id)}`); - } else { - // Update defaults so that "reload saved query" functions correctly - setAppState(getStateDefaults()); - chrome.docTitle.change(savedSearch.lastSavedTitle); - chrome.setBreadcrumbs([ - { - text: discoverBreadcrumbsTitle, - href: '#/', - }, - { text: savedSearch.title }, - ]); - } - } - }); - return { id }; - } catch (saveError) { - toastNotifications.addDanger({ - title: i18n.translate('discover.notifications.notSavedSearchTitle', { - defaultMessage: `Search '{savedSearchTitle}' was not saved.`, - values: { - savedSearchTitle: savedSearch.title, - }, - }), - text: saveError.message, - }); - return { error: saveError }; - } - } - $scope.opts.fetch = $scope.fetch = function () { // ignore requests to fetch before the app inits if (!init.complete) return; @@ -907,16 +647,11 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise $scope.hits = resp.hits.total; $scope.rows = resp.hits.hits; - // if we haven't counted yet, reset the counts - const counts = ($scope.fieldCounts = $scope.fieldCounts || {}); - - $scope.rows.forEach((hit) => { - const fields = Object.keys($scope.indexPattern.flattenHit(hit)); - fields.forEach((fieldName) => { - counts[fieldName] = (counts[fieldName] || 0) + 1; - }); - }); - + $scope.fieldCounts = calcFieldCounts( + $scope.fieldCounts || {}, + resp.hits.hits, + $scope.indexPattern + ); $scope.fetchStatus = fetchStatuses.COMPLETE; } @@ -944,13 +679,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }; }; - $scope.toMoment = function (datetime) { - if (!datetime) { - return; - } - return moment(datetime).format(config.get('dateFormat')); - }; - $scope.resetQuery = function () { history.push( $route.current.params.id ? `/view/${encodeURIComponent($route.current.params.id)}` : '/' @@ -979,20 +707,11 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }; $scope.updateDataSource = () => { - const { indexPattern, searchSource } = $scope; - searchSource - .setField('index', $scope.indexPattern) - .setField('size', $scope.opts.sampleSize) - .setField( - 'sort', - getSortForSearchSource( - $scope.state.sort, - indexPattern, - config.get(SORT_DEFAULT_ORDER_SETTING) - ) - ) - .setField('query', data.query.queryString.getQuery() || null) - .setField('filter', filterManager.getFilters()); + updateSearchSource($scope.searchSource, { + indexPattern: $scope.indexPattern, + services, + sort: $scope.state.sort, + }); return Promise.resolve(); }; @@ -1044,11 +763,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise const columns = columnActions.moveColumn($scope.state.columns, columnName, newIndex); setAppState({ columns }); }; - - $scope.scrollToTop = function () { - $window.scrollTo(0, 0); - }; - async function setupVisualization() { // If no timefield has been specified we don't create a histogram of messages if (!getTimeField()) return; @@ -1085,62 +799,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }); } - function getIndexPatternWarning(index) { - return i18n.translate('discover.valueIsNotConfiguredIndexPatternIDWarningTitle', { - defaultMessage: '{stateVal} is not a configured index pattern ID', - values: { - stateVal: `"${index}"`, - }, - }); - } - - function resolveIndexPatternLoading() { - const { - loaded: loadedIndexPattern, - stateVal, - stateValFound, - } = $route.current.locals.savedObjects.ip; - - const ownIndexPattern = $scope.searchSource.getOwnField('index'); - - if (ownIndexPattern && !stateVal) { - return ownIndexPattern; - } - - if (stateVal && !stateValFound) { - const warningTitle = getIndexPatternWarning(); - - if (ownIndexPattern) { - toastNotifications.addWarning({ - title: warningTitle, - text: i18n.translate('discover.showingSavedIndexPatternWarningDescription', { - defaultMessage: - 'Showing the saved index pattern: "{ownIndexPatternTitle}" ({ownIndexPatternId})', - values: { - ownIndexPatternTitle: ownIndexPattern.title, - ownIndexPatternId: ownIndexPattern.id, - }, - }), - }); - return ownIndexPattern; - } - - toastNotifications.addWarning({ - title: warningTitle, - text: i18n.translate('discover.showingDefaultIndexPatternWarningDescription', { - defaultMessage: - 'Showing the default index pattern: "{loadedIndexPatternTitle}" ({loadedIndexPatternId})', - values: { - loadedIndexPatternTitle: loadedIndexPattern.title, - loadedIndexPatternId: loadedIndexPattern.id, - }, - }), - }); - } - - return loadedIndexPattern; - } - addHelpMenuToAppChrome(chrome); init(); diff --git a/src/plugins/discover/public/application/angular/discover_state.test.ts b/src/plugins/discover/public/application/angular/discover_state.test.ts index b7b36ca96016..2914ce8f17a0 100644 --- a/src/plugins/discover/public/application/angular/discover_state.test.ts +++ b/src/plugins/discover/public/application/angular/discover_state.test.ts @@ -29,7 +29,7 @@ describe('Test discover state', () => { history = createBrowserHistory(); history.push('/'); state = getState({ - defaultAppState: { index: 'test' }, + getStateDefaults: () => ({ index: 'test' }), history, }); await state.replaceUrlAppState({}); @@ -84,7 +84,7 @@ describe('Test discover state with legacy migration', () => { "/#?_a=(query:(query_string:(analyze_wildcard:!t,query:'type:nice%20name:%22yeah%22')))" ); state = getState({ - defaultAppState: { index: 'test' }, + getStateDefaults: () => ({ index: 'test' }), history, }); expect(state.appStateContainer.getState()).toMatchInlineSnapshot(` diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts index 5ddb6a92b5fd..3c6ef1d3e433 100644 --- a/src/plugins/discover/public/application/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -65,7 +65,7 @@ interface GetStateParams { /** * Default state used for merging with with URL state to get the initial state */ - defaultAppState?: AppState; + getStateDefaults?: () => AppState; /** * Determins the use of long vs. short/hashed urls */ @@ -123,7 +123,11 @@ export interface GetStateReturn { /** * Returns whether the current app state is different to the initial state */ - isAppStateDirty: () => void; + isAppStateDirty: () => boolean; + /** + * Reset AppState to default, discarding all changes + */ + resetAppState: () => void; } const APP_STATE_URL_KEY = '_a'; @@ -132,11 +136,12 @@ const APP_STATE_URL_KEY = '_a'; * Used to sync URL with UI state */ export function getState({ - defaultAppState = {}, + getStateDefaults, storeInSessionStorage = false, history, toasts, }: GetStateParams): GetStateReturn { + const defaultAppState = getStateDefaults ? getStateDefaults() : {}; const stateStorage = createKbnUrlStateStorage({ useHash: storeInSessionStorage, history, @@ -185,6 +190,10 @@ export function getState({ resetInitialAppState: () => { initialAppState = appStateContainer.getState(); }, + resetAppState: () => { + const defaultState = getStateDefaults ? getStateDefaults() : {}; + setState(appStateContainerModified, defaultState); + }, getPreviousAppState: () => previousAppState, flushToUrl: () => stateStorage.flush(), isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()), diff --git a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap similarity index 96% rename from src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap rename to src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap index 42cd8613b1de..2c2674b158bf 100644 --- a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap +++ b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap @@ -3,7 +3,7 @@ exports[`render 1`] = ` { + const topNavLinks = getTopNavLinks({ + getFieldCounts: jest.fn(), + indexPattern: indexPatternMock, + inspectorAdapters: inspectorPluginMock, + navigateTo: jest.fn(), + savedSearch: savedSearchMock, + services, + state, + }); + expect(topNavLinks).toMatchInlineSnapshot(` + Array [ + Object { + "description": "New Search", + "id": "new", + "label": "New", + "run": [Function], + "testId": "discoverNewButton", + }, + Object { + "description": "Save Search", + "id": "save", + "label": "Save", + "run": [Function], + "testId": "discoverSaveButton", + }, + Object { + "description": "Open Saved Search", + "id": "open", + "label": "Open", + "run": [Function], + "testId": "discoverOpenButton", + }, + Object { + "description": "Share Search", + "id": "share", + "label": "Share", + "run": [Function], + "testId": "shareTopNavButton", + }, + Object { + "description": "Open Inspector for search", + "id": "inspect", + "label": "Inspect", + "run": [Function], + "testId": "openInspectorButton", + }, + ] + `); +}); diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts new file mode 100644 index 000000000000..62542e9ace4d --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts @@ -0,0 +1,148 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { i18n } from '@kbn/i18n'; +import { showOpenSearchPanel } from './show_open_search_panel'; +import { getSharingData } from '../../helpers/get_sharing_data'; +import { unhashUrl } from '../../../../../kibana_utils/public'; +import { DiscoverServices } from '../../../build_services'; +import { Adapters } from '../../../../../inspector/common/adapters'; +import { SavedSearch } from '../../../saved_searches'; +import { onSaveSearch } from './on_save_search'; +import { GetStateReturn } from '../../angular/discover_state'; +import { IndexPattern } from '../../../kibana_services'; + +/** + * Helper function to build the top nav links + */ +export const getTopNavLinks = ({ + getFieldCounts, + indexPattern, + inspectorAdapters, + navigateTo, + savedSearch, + services, + state, +}: { + getFieldCounts: () => Promise>; + indexPattern: IndexPattern; + inspectorAdapters: Adapters; + navigateTo: (url: string) => void; + savedSearch: SavedSearch; + services: DiscoverServices; + state: GetStateReturn; +}) => { + const newSearch = { + id: 'new', + label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { + defaultMessage: 'New', + }), + description: i18n.translate('discover.localMenu.newSearchDescription', { + defaultMessage: 'New Search', + }), + run: () => navigateTo('/'), + testId: 'discoverNewButton', + }; + + const saveSearch = { + id: 'save', + label: i18n.translate('discover.localMenu.saveTitle', { + defaultMessage: 'Save', + }), + description: i18n.translate('discover.localMenu.saveSearchDescription', { + defaultMessage: 'Save Search', + }), + testId: 'discoverSaveButton', + run: () => onSaveSearch({ savedSearch, services, indexPattern, navigateTo, state }), + }; + + const openSearch = { + id: 'open', + label: i18n.translate('discover.localMenu.openTitle', { + defaultMessage: 'Open', + }), + description: i18n.translate('discover.localMenu.openSavedSearchDescription', { + defaultMessage: 'Open Saved Search', + }), + testId: 'discoverOpenButton', + run: () => + showOpenSearchPanel({ + makeUrl: (searchId) => `#/view/${encodeURIComponent(searchId)}`, + I18nContext: services.core.i18n.Context, + }), + }; + + const shareSearch = { + id: 'share', + label: i18n.translate('discover.localMenu.shareTitle', { + defaultMessage: 'Share', + }), + description: i18n.translate('discover.localMenu.shareSearchDescription', { + defaultMessage: 'Share Search', + }), + testId: 'shareTopNavButton', + run: async (anchorElement: HTMLElement) => { + if (!services.share) { + return; + } + const sharingData = await getSharingData( + savedSearch.searchSource, + state.appStateContainer.getState(), + services.uiSettings, + getFieldCounts + ); + services.share.toggleShareContextMenu({ + anchorElement, + allowEmbed: false, + allowShortUrl: !!services.capabilities.discover.createShortUrl, + shareableUrl: unhashUrl(window.location.href), + objectId: savedSearch.id, + objectType: 'search', + sharingData: { + ...sharingData, + title: savedSearch.title, + }, + isDirty: !savedSearch.id || state.isAppStateDirty(), + }); + }, + }; + + const inspectSearch = { + id: 'inspect', + label: i18n.translate('discover.localMenu.inspectTitle', { + defaultMessage: 'Inspect', + }), + description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', { + defaultMessage: 'Open Inspector for search', + }), + testId: 'openInspectorButton', + run: () => { + services.inspector.open(inspectorAdapters, { + title: savedSearch.title, + }); + }, + }; + + return [ + newSearch, + ...(services.capabilities.discover.save ? [saveSearch] : []), + openSearch, + shareSearch, + inspectSearch, + ]; +}; diff --git a/src/plugins/discover/public/application/components/top_nav/on_save_search.test.tsx b/src/plugins/discover/public/application/components/top_nav/on_save_search.test.tsx new file mode 100644 index 000000000000..b96af355fafd --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/on_save_search.test.tsx @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { showSaveModal } from '../../../../../saved_objects/public'; +jest.mock('../../../../../saved_objects/public'); + +import { onSaveSearch } from './on_save_search'; +import { indexPatternMock } from '../../../__mocks__/index_pattern'; +import { savedSearchMock } from '../../../__mocks__/saved_search'; +import { DiscoverServices } from '../../../build_services'; +import { GetStateReturn } from '../../angular/discover_state'; +import { i18nServiceMock } from '../../../../../../core/public/mocks'; + +test('onSaveSearch', async () => { + const serviceMock = ({ + core: { + i18n: i18nServiceMock.create(), + }, + } as unknown) as DiscoverServices; + const stateMock = ({} as unknown) as GetStateReturn; + + await onSaveSearch({ + indexPattern: indexPatternMock, + navigateTo: jest.fn(), + savedSearch: savedSearchMock, + services: serviceMock, + state: stateMock, + }); + + expect(showSaveModal).toHaveBeenCalled(); +}); diff --git a/src/plugins/discover/public/application/components/top_nav/on_save_search.tsx b/src/plugins/discover/public/application/components/top_nav/on_save_search.tsx new file mode 100644 index 000000000000..c3343968a468 --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/on_save_search.tsx @@ -0,0 +1,158 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { SavedObjectSaveModal, showSaveModal } from '../../../../../saved_objects/public'; +import { SavedSearch } from '../../../saved_searches'; +import { IndexPattern } from '../../../../../data/common/index_patterns/index_patterns'; +import { DiscoverServices } from '../../../build_services'; +import { GetStateReturn } from '../../angular/discover_state'; +import { setBreadcrumbsTitle } from '../../helpers/breadcrumbs'; +import { persistSavedSearch } from '../../helpers/persist_saved_search'; + +async function saveDataSource({ + indexPattern, + navigateTo, + savedSearch, + saveOptions, + services, + state, +}: { + indexPattern: IndexPattern; + navigateTo: (url: string) => void; + savedSearch: SavedSearch; + saveOptions: { + confirmOverwrite: boolean; + isTitleDuplicateConfirmed: boolean; + onTitleDuplicate: () => void; + }; + services: DiscoverServices; + state: GetStateReturn; +}) { + const prevSavedSearchId = savedSearch.id; + function onSuccess(id: string) { + if (id) { + services.toastNotifications.addSuccess({ + title: i18n.translate('discover.notifications.savedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was saved`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + 'data-test-subj': 'saveSearchSuccess', + }); + + if (savedSearch.id !== prevSavedSearchId) { + navigateTo(`/view/${encodeURIComponent(savedSearch.id)}`); + } else { + // Update defaults so that "reload saved query" functions correctly + state.resetAppState(); + services.chrome.docTitle.change(savedSearch.lastSavedTitle!); + setBreadcrumbsTitle(savedSearch, services.chrome); + } + } + } + + function onError(error: Error) { + services.toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.notSavedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was not saved.`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + text: error.message, + }); + } + return persistSavedSearch(savedSearch, { + indexPattern, + onError, + onSuccess, + saveOptions, + services, + state: state.appStateContainer.getState(), + }); +} + +export async function onSaveSearch({ + indexPattern, + navigateTo, + savedSearch, + services, + state, +}: { + indexPattern: IndexPattern; + navigateTo: (path: string) => void; + savedSearch: SavedSearch; + services: DiscoverServices; + state: GetStateReturn; +}) { + const onSave = async ({ + newTitle, + newCopyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }: { + newTitle: string; + newCopyOnSave: boolean; + isTitleDuplicateConfirmed: boolean; + onTitleDuplicate: () => void; + }) => { + const currentTitle = savedSearch.title; + savedSearch.title = newTitle; + savedSearch.copyOnSave = newCopyOnSave; + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + const response = await saveDataSource({ + indexPattern, + saveOptions, + services, + navigateTo, + savedSearch, + state, + }); + // If the save wasn't successful, put the original values back. + if (!response.id || response.error) { + savedSearch.title = currentTitle; + } else { + state.resetInitialAppState(); + } + return response; + }; + + const saveModal = ( + {}} + title={savedSearch.title} + showCopyOnSave={!!savedSearch.id} + objectType="search" + description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { + defaultMessage: + 'Save your Discover search so you can use it in visualizations and dashboards', + })} + showDescription={false} + /> + ); + showSaveModal(saveModal, services.core.i18n.Context); +} diff --git a/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.js b/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.tsx similarity index 89% rename from src/plugins/discover/public/application/components/top_nav/open_search_panel.test.js rename to src/plugins/discover/public/application/components/top_nav/open_search_panel.test.tsx index 50ab02c8e273..4b06964c7bc3 100644 --- a/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.js +++ b/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.tsx @@ -24,7 +24,7 @@ jest.mock('../../../kibana_services', () => { return { getServices: () => ({ core: { uiSettings: {}, savedObjects: {} }, - addBasePath: (path) => path, + addBasePath: (path: string) => path, }), }; }); @@ -32,6 +32,6 @@ jest.mock('../../../kibana_services', () => { import { OpenSearchPanel } from './open_search_panel'; test('render', () => { - const component = shallow( {}} makeUrl={() => {}} />); + const component = shallow(); expect(component).toMatchSnapshot(); }); diff --git a/src/plugins/discover/public/application/components/top_nav/open_search_panel.js b/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx similarity index 94% rename from src/plugins/discover/public/application/components/top_nav/open_search_panel.js rename to src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx index 9a6840c29bf1..62441f7d827d 100644 --- a/src/plugins/discover/public/application/components/top_nav/open_search_panel.js +++ b/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx @@ -16,9 +16,7 @@ * specific language governing permissions and limitations * under the License. */ - import React from 'react'; -import PropTypes from 'prop-types'; import rison from 'rison-node'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -37,7 +35,12 @@ import { getServices } from '../../../kibana_services'; const SEARCH_OBJECT_TYPE = 'search'; -export function OpenSearchPanel(props) { +interface OpenSearchPanelProps { + onClose: () => void; + makeUrl: (id: string) => string; +} + +export function OpenSearchPanel(props: OpenSearchPanelProps) { const { core: { uiSettings, savedObjects }, addBasePath, @@ -102,8 +105,3 @@ export function OpenSearchPanel(props) { ); } - -OpenSearchPanel.propTypes = { - onClose: PropTypes.func.isRequired, - makeUrl: PropTypes.func.isRequired, -}; diff --git a/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.js b/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx similarity index 87% rename from src/plugins/discover/public/application/components/top_nav/show_open_search_panel.js rename to src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx index e40d700b4888..d9a5cdcb063d 100644 --- a/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.js +++ b/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx @@ -19,11 +19,18 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { I18nStart } from 'kibana/public'; import { OpenSearchPanel } from './open_search_panel'; let isOpen = false; -export function showOpenSearchPanel({ makeUrl, I18nContext }) { +export function showOpenSearchPanel({ + makeUrl, + I18nContext, +}: { + makeUrl: (path: string) => string; + I18nContext: I18nStart['Context']; +}) { if (isOpen) { return; } diff --git a/src/plugins/discover/public/application/helpers/breadcrumbs.ts b/src/plugins/discover/public/application/helpers/breadcrumbs.ts index 17492b02f7ea..96a9f546a063 100644 --- a/src/plugins/discover/public/application/helpers/breadcrumbs.ts +++ b/src/plugins/discover/public/application/helpers/breadcrumbs.ts @@ -17,7 +17,9 @@ * under the License. */ +import { ChromeStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; +import { SavedSearch } from '../../saved_searches'; export function getRootBreadcrumbs() { return [ @@ -38,3 +40,29 @@ export function getSavedSearchBreadcrumbs($route: any) { }, ]; } + +/** + * Helper function to set the Discover's breadcrumb + * if there's an active savedSearch, its title is appended + */ +export function setBreadcrumbsTitle(savedSearch: SavedSearch, chrome: ChromeStart) { + const discoverBreadcrumbsTitle = i18n.translate('discover.discoverBreadcrumbTitle', { + defaultMessage: 'Discover', + }); + + if (savedSearch.id && savedSearch.title) { + chrome.setBreadcrumbs([ + { + text: discoverBreadcrumbsTitle, + href: '#/', + }, + { text: savedSearch.title }, + ]); + } else { + chrome.setBreadcrumbs([ + { + text: discoverBreadcrumbsTitle, + }, + ]); + } +} diff --git a/src/plugins/discover/public/application/helpers/calc_field_counts.test.ts b/src/plugins/discover/public/application/helpers/calc_field_counts.test.ts new file mode 100644 index 000000000000..ce3319bf8a66 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/calc_field_counts.test.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { calcFieldCounts } from './calc_field_counts'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; + +describe('calcFieldCounts', () => { + test('returns valid field count data', async () => { + const rows = [ + { _id: 1, _source: { message: 'test1', bytes: 20 } }, + { _id: 2, _source: { name: 'test2', extension: 'jpg' } }, + ]; + const result = calcFieldCounts({}, rows, indexPatternMock); + expect(result).toMatchInlineSnapshot(` + Object { + "_index": 2, + "_score": 2, + "bytes": 1, + "extension": 1, + "message": 1, + "name": 1, + } + `); + }); + test('updates field count data', async () => { + const rows = [ + { _id: 1, _source: { message: 'test1', bytes: 20 } }, + { _id: 2, _source: { name: 'test2', extension: 'jpg' } }, + ]; + const result = calcFieldCounts({ message: 2 }, rows, indexPatternMock); + expect(result).toMatchInlineSnapshot(` + Object { + "_index": 2, + "_score": 2, + "bytes": 1, + "extension": 1, + "message": 3, + "name": 1, + } + `); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/calc_field_counts.ts b/src/plugins/discover/public/application/helpers/calc_field_counts.ts new file mode 100644 index 000000000000..02c0299995e1 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/calc_field_counts.ts @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { IndexPattern } from '../../kibana_services'; + +/** + * This function is recording stats of the available fields, for usage in sidebar and sharing + * Note that this values aren't displayed, but used for internal calculations + */ +export function calcFieldCounts( + counts = {} as Record, + rows: Array>, + indexPattern: IndexPattern +) { + for (const hit of rows) { + const fields = Object.keys(indexPattern.flattenHit(hit)); + for (const fieldName of fields) { + counts[fieldName] = (counts[fieldName] || 0) + 1; + } + } + + return counts; +} diff --git a/src/plugins/discover/public/application/helpers/get_index_pattern_id.ts b/src/plugins/discover/public/application/helpers/get_index_pattern_id.ts deleted file mode 100644 index 601f892e3c56..000000000000 --- a/src/plugins/discover/public/application/helpers/get_index_pattern_id.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { IIndexPattern } from '../../../../data/common/index_patterns'; - -export function findIndexPatternById( - indexPatterns: IIndexPattern[], - id: string -): IIndexPattern | undefined { - if (!Array.isArray(indexPatterns) || !id) { - return; - } - return indexPatterns.find((o) => o.id === id); -} - -/** - * Checks if the given defaultIndex exists and returns - * the first available index pattern id if not - */ -export function getFallbackIndexPatternId( - indexPatterns: IIndexPattern[], - defaultIndex: string = '' -): string { - if (defaultIndex && findIndexPatternById(indexPatterns, defaultIndex)) { - return defaultIndex; - } - return !indexPatterns || !indexPatterns.length || !indexPatterns[0].id ? '' : indexPatterns[0].id; -} - -/** - * A given index pattern id is checked for existence and a fallback is provided if it doesn't exist - * The provided defaultIndex is usually configured in Advanced Settings, if it's also invalid - * the first entry of the given list of Indexpatterns is used - */ -export function getIndexPatternId( - id: string = '', - indexPatterns: IIndexPattern[], - defaultIndex: string = '' -): string { - if (!id || !findIndexPatternById(indexPatterns, id)) { - return getFallbackIndexPatternId(indexPatterns, defaultIndex); - } - return id; -} diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts new file mode 100644 index 000000000000..8ce9789d1dc8 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getSharingData } from './get_sharing_data'; +import { IUiSettingsClient } from 'kibana/public'; +import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; + +describe('getSharingData', () => { + test('returns valid data for sharing', async () => { + const searchSourceMock = createSearchSourceMock({ index: indexPatternMock }); + const result = await getSharingData( + searchSourceMock, + { columns: [] }, + ({ + get: () => { + return false; + }, + } as unknown) as IUiSettingsClient, + () => Promise.resolve({}) + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [], + "indexPatternId": "the-index-pattern-id", + "metaFields": Array [ + "_index", + "_score", + ], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [], + }, + "docvalue_fields": Array [], + "query": Object { + "bool": Object { + "filter": Array [], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [], + "stored_fields": Array [], + }, + "index": "the-index-pattern-title", + }, + } + `); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.ts new file mode 100644 index 000000000000..0edaa356cba7 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.ts @@ -0,0 +1,88 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { IUiSettingsClient } from 'kibana/public'; +import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; +import { getSortForSearchSource } from '../angular/doc_table'; +import { SearchSource } from '../../../../data/common'; +import { AppState } from '../angular/discover_state'; +import { SortOrder } from '../../saved_searches/types'; + +const getSharingDataFields = async ( + getFieldCounts: () => Promise>, + selectedFields: string[], + timeFieldName: string, + hideTimeColumn: boolean +) => { + if (selectedFields.length === 1 && selectedFields[0] === '_source') { + const fieldCounts = await getFieldCounts(); + return { + searchFields: undefined, + selectFields: Object.keys(fieldCounts).sort(), + }; + } + + const fields = + timeFieldName && !hideTimeColumn ? [timeFieldName, ...selectedFields] : selectedFields; + return { + searchFields: fields, + selectFields: fields, + }; +}; + +/** + * Preparing data to share the current state as link or CSV/Report + */ +export async function getSharingData( + currentSearchSource: SearchSource, + state: AppState, + config: IUiSettingsClient, + getFieldCounts: () => Promise> +) { + const searchSource = currentSearchSource.createCopy(); + const index = searchSource.getField('index')!; + + const { searchFields, selectFields } = await getSharingDataFields( + getFieldCounts, + state.columns || [], + index.timeFieldName || '', + config.get(DOC_HIDE_TIME_COLUMN_SETTING) + ); + searchSource.setField('fields', searchFields); + searchSource.setField( + 'sort', + getSortForSearchSource(state.sort as SortOrder[], index, config.get(SORT_DEFAULT_ORDER_SETTING)) + ); + searchSource.removeField('highlight'); + searchSource.removeField('highlightAll'); + searchSource.removeField('aggs'); + searchSource.removeField('size'); + + const body = await searchSource.getSearchRequestBody(); + + return { + searchRequest: { + index: index.title, + body, + }, + fields: selectFields, + metaFields: index.metaFields, + conflictedTypesFields: index.fields.filter((f) => f.type === 'conflict').map((f) => f.name), + indexPatternId: index.id, + }; +} diff --git a/src/plugins/discover/public/application/helpers/persist_saved_search.ts b/src/plugins/discover/public/application/helpers/persist_saved_search.ts new file mode 100644 index 000000000000..8e956eff598f --- /dev/null +++ b/src/plugins/discover/public/application/helpers/persist_saved_search.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { updateSearchSource } from './update_search_source'; +import { IndexPattern } from '../../../../data/public'; +import { SavedSearch } from '../../saved_searches'; +import { AppState } from '../angular/discover_state'; +import { SortOrder } from '../../saved_searches/types'; +import { SavedObjectSaveOpts } from '../../../../saved_objects/public'; +import { DiscoverServices } from '../../build_services'; + +/** + * Helper function to update and persist the given savedSearch + */ +export async function persistSavedSearch( + savedSearch: SavedSearch, + { + indexPattern, + onError, + onSuccess, + services, + saveOptions, + state, + }: { + indexPattern: IndexPattern; + onError: (error: Error, savedSearch: SavedSearch) => void; + onSuccess: (id: string) => void; + saveOptions: SavedObjectSaveOpts; + services: DiscoverServices; + state: AppState; + } +) { + updateSearchSource(savedSearch.searchSource, { + indexPattern, + services, + sort: state.sort as SortOrder[], + }); + + savedSearch.columns = state.columns || []; + savedSearch.sort = (state.sort as SortOrder[]) || []; + + try { + const id = await savedSearch.save(saveOptions); + onSuccess(id); + return { id }; + } catch (saveError) { + onError(saveError, savedSearch); + return { error: saveError }; + } +} diff --git a/src/plugins/discover/public/application/helpers/resolve_index_pattern.test.ts b/src/plugins/discover/public/application/helpers/resolve_index_pattern.test.ts new file mode 100644 index 000000000000..826f738c381a --- /dev/null +++ b/src/plugins/discover/public/application/helpers/resolve_index_pattern.test.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + loadIndexPattern, + getFallbackIndexPatternId, + IndexPatternSavedObject, +} from './resolve_index_pattern'; +import { indexPatternsMock } from '../../__mocks__/index_patterns'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; +import { configMock } from '../../__mocks__/config'; + +describe('Resolve index pattern tests', () => { + test('returns valid data for an existing index pattern', async () => { + const indexPatternId = 'the-index-pattern-id'; + const result = await loadIndexPattern(indexPatternId, indexPatternsMock, configMock); + expect(result.loaded).toEqual(indexPatternMock); + expect(result.stateValFound).toEqual(true); + expect(result.stateVal).toEqual(indexPatternId); + }); + test('returns fallback data for an invalid index pattern', async () => { + const indexPatternId = 'invalid-id'; + const result = await loadIndexPattern(indexPatternId, indexPatternsMock, configMock); + expect(result.loaded).toEqual(indexPatternMock); + expect(result.stateValFound).toBe(false); + expect(result.stateVal).toBe(indexPatternId); + }); + test('getFallbackIndexPatternId with an empty indexPatterns array', async () => { + const result = await getFallbackIndexPatternId([], ''); + expect(result).toBe(''); + }); + test('getFallbackIndexPatternId with an indexPatterns array', async () => { + const list = await indexPatternsMock.getCache(); + const result = await getFallbackIndexPatternId( + (list as unknown) as IndexPatternSavedObject[], + '' + ); + expect(result).toBe('the-index-pattern-id'); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/resolve_index_pattern.ts b/src/plugins/discover/public/application/helpers/resolve_index_pattern.ts new file mode 100644 index 000000000000..61f7f087501b --- /dev/null +++ b/src/plugins/discover/public/application/helpers/resolve_index_pattern.ts @@ -0,0 +1,158 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient, SavedObject, ToastsStart } from 'kibana/public'; +import { IndexPattern } from '../../kibana_services'; +import { IndexPatternsService, SearchSource } from '../../../../data/common'; + +export type IndexPatternSavedObject = SavedObject & { title: string }; + +interface IndexPatternData { + /** + * List of existing index patterns + */ + list: IndexPatternSavedObject[]; + /** + * Loaded index pattern (might be default index pattern if requested was not found) + */ + loaded: IndexPattern; + /** + * Id of the requested index pattern + */ + stateVal: string; + /** + * Determines if requested index pattern was found + */ + stateValFound: boolean; +} + +export function findIndexPatternById( + indexPatterns: IndexPatternSavedObject[], + id: string +): IndexPatternSavedObject | undefined { + if (!Array.isArray(indexPatterns) || !id) { + return; + } + return indexPatterns.find((o) => o.id === id); +} + +/** + * Checks if the given defaultIndex exists and returns + * the first available index pattern id if not + */ +export function getFallbackIndexPatternId( + indexPatterns: IndexPatternSavedObject[], + defaultIndex: string = '' +): string { + if (defaultIndex && findIndexPatternById(indexPatterns, defaultIndex)) { + return defaultIndex; + } + return indexPatterns && indexPatterns[0]?.id ? indexPatterns[0].id : ''; +} + +/** + * A given index pattern id is checked for existence and a fallback is provided if it doesn't exist + * The provided defaultIndex is usually configured in Advanced Settings, if it's also invalid + * the first entry of the given list of Indexpatterns is used + */ +export function getIndexPatternId( + id: string = '', + indexPatterns: IndexPatternSavedObject[] = [], + defaultIndex: string = '' +): string { + if (!id || !findIndexPatternById(indexPatterns, id)) { + return getFallbackIndexPatternId(indexPatterns, defaultIndex); + } + return id; +} + +/** + * Function to load the given index pattern by id, providing a fallback if it doesn't exist + */ +export async function loadIndexPattern( + id: string, + indexPatterns: IndexPatternsService, + config: IUiSettingsClient +): Promise { + const indexPatternList = ((await indexPatterns.getCache()) as unknown) as IndexPatternSavedObject[]; + + const actualId = getIndexPatternId(id, indexPatternList, config.get('defaultIndex')); + return { + list: indexPatternList || [], + loaded: await indexPatterns.get(actualId), + stateVal: id, + stateValFound: !!id && actualId === id, + }; +} + +/** + * Function used in the discover controller to message the user about the state of the current + * index pattern + */ +export function resolveIndexPattern( + ip: IndexPatternData, + searchSource: SearchSource, + toastNotifications: ToastsStart +) { + const { loaded: loadedIndexPattern, stateVal, stateValFound } = ip; + + const ownIndexPattern = searchSource.getOwnField('index'); + + if (ownIndexPattern && !stateVal) { + return ownIndexPattern; + } + + if (stateVal && !stateValFound) { + const warningTitle = i18n.translate('discover.valueIsNotConfiguredIndexPatternIDWarningTitle', { + defaultMessage: '{stateVal} is not a configured index pattern ID', + values: { + stateVal: `"${stateVal}"`, + }, + }); + + if (ownIndexPattern) { + toastNotifications.addWarning({ + title: warningTitle, + text: i18n.translate('discover.showingSavedIndexPatternWarningDescription', { + defaultMessage: + 'Showing the saved index pattern: "{ownIndexPatternTitle}" ({ownIndexPatternId})', + values: { + ownIndexPatternTitle: ownIndexPattern.title, + ownIndexPatternId: ownIndexPattern.id, + }, + }), + }); + return ownIndexPattern; + } + + toastNotifications.addWarning({ + title: warningTitle, + text: i18n.translate('discover.showingDefaultIndexPatternWarningDescription', { + defaultMessage: + 'Showing the default index pattern: "{loadedIndexPatternTitle}" ({loadedIndexPatternId})', + values: { + loadedIndexPatternTitle: loadedIndexPattern.title, + loadedIndexPatternId: loadedIndexPattern.id, + }, + }), + }); + } + + return loadedIndexPattern; +} diff --git a/src/plugins/discover/public/application/helpers/update_search_source.test.ts b/src/plugins/discover/public/application/helpers/update_search_source.test.ts new file mode 100644 index 000000000000..91832325432e --- /dev/null +++ b/src/plugins/discover/public/application/helpers/update_search_source.test.ts @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { updateSearchSource } from './update_search_source'; +import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; +import { IUiSettingsClient } from 'kibana/public'; +import { DiscoverServices } from '../../build_services'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { SAMPLE_SIZE_SETTING } from '../../../common'; +import { SortOrder } from '../../saved_searches/types'; + +describe('updateSearchSource', () => { + test('updates a given search source', async () => { + const searchSourceMock = createSearchSourceMock({}); + const sampleSize = 250; + const result = updateSearchSource(searchSourceMock, { + indexPattern: indexPatternMock, + services: ({ + data: dataPluginMock.createStartContract(), + uiSettings: ({ + get: (key: string) => { + if (key === SAMPLE_SIZE_SETTING) { + return sampleSize; + } + return false; + }, + } as unknown) as IUiSettingsClient, + } as unknown) as DiscoverServices, + sort: [] as SortOrder[], + }); + expect(result.getField('index')).toEqual(indexPatternMock); + expect(result.getField('size')).toEqual(sampleSize); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/update_search_source.ts b/src/plugins/discover/public/application/helpers/update_search_source.ts new file mode 100644 index 000000000000..324dc8a48457 --- /dev/null +++ b/src/plugins/discover/public/application/helpers/update_search_source.ts @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { getSortForSearchSource } from '../angular/doc_table'; +import { SAMPLE_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; +import { IndexPattern, ISearchSource } from '../../../../data/common/'; +import { SortOrder } from '../../saved_searches/types'; +import { DiscoverServices } from '../../build_services'; + +/** + * Helper function to update the given searchSource before fetching/sharing/persisting + */ +export function updateSearchSource( + searchSource: ISearchSource, + { + indexPattern, + services, + sort, + }: { + indexPattern: IndexPattern; + services: DiscoverServices; + sort: SortOrder[]; + } +) { + const { uiSettings, data } = services; + const usedSort = getSortForSearchSource( + sort, + indexPattern, + uiSettings.get(SORT_DEFAULT_ORDER_SETTING) + ); + + searchSource + .setField('index', indexPattern) + .setField('size', uiSettings.get(SAMPLE_SIZE_SETTING)) + .setField('sort', usedSort) + .setField('query', data.query.queryString.getQuery() || null) + .setField('filter', data.query.filterManager.getFilters()); + return searchSource; +} diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index 13361cb647dd..d5e5dd765a36 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -17,18 +17,21 @@ * under the License. */ -import { ISearchSource } from '../../../data/public'; +import { SearchSource } from '../../../data/public'; +import { SavedObjectSaveOpts } from '../../../saved_objects/public'; export type SortOrder = [string, string]; export interface SavedSearch { readonly id: string; title: string; - searchSource: ISearchSource; + searchSource: SearchSource; description?: string; columns: string[]; sort: SortOrder[]; destroy: () => void; + save: (saveOptions: SavedObjectSaveOpts) => Promise; lastSavedTitle?: string; + copyOnSave?: boolean; } export interface SavedSearchLoader { get: (id: string) => Promise; From 09e326e1368f14647fb79d2fc8077aa87aaaaff9 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 25 Nov 2020 12:12:43 +0100 Subject: [PATCH 57/89] [Lens] Fix label input debouncing (#84121) --- .../dimension_panel/dimension_editor.tsx | 6 +----- .../operations/definitions/filters/filter_popover.tsx | 4 ---- .../definitions/shared_components/label_input.tsx | 6 +----- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index e5c05a1cf8c7..0a67c157bd83 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -6,7 +6,7 @@ import './dimension_editor.scss'; import _ from 'lodash'; -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiListGroup, @@ -46,10 +46,6 @@ export interface DimensionEditorProps extends IndexPatternDimensionEditorProps { const LabelInput = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => { const [inputValue, setInputValue] = useState(value); - useEffect(() => { - setInputValue(value); - }, [value, setInputValue]); - const onChangeDebounced = useMemo(() => _.debounce(onChange, 256), [onChange]); const handleInputChange = (e: React.ChangeEvent) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx index b9d9d6306b9a..ca84c072be5c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx @@ -110,10 +110,6 @@ export const QueryInput = ({ }) => { const [inputValue, setInputValue] = useState(value); - React.useEffect(() => { - setInputValue(value); - }, [value, setInputValue]); - useDebounce(() => onChange(inputValue), 256, [inputValue]); const handleInputChange = (input: Query) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx index f0ee30bb4331..ddcb5633b376 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import { EuiFieldText, keys } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -28,10 +28,6 @@ export const LabelInput = ({ }) => { const [inputValue, setInputValue] = useState(value); - useEffect(() => { - setInputValue(value); - }, [value, setInputValue]); - useDebounce(() => onChange(inputValue), 256, [inputValue]); const handleInputChange = (e: React.ChangeEvent) => { From c1b807eda3d34bff02cc54b91f2648392da2734c Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Wed, 25 Nov 2020 11:36:31 +0000 Subject: [PATCH 58/89] [ML] Fix Anomaly Explorer population charts when multiple causes in anomaly (#84254) --- x-pack/plugins/ml/public/application/util/chart_utils.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js index f2dec5f16df1..d142d2e24665 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.js @@ -77,7 +77,10 @@ export function chartExtendedLimits(data = [], functionDescription) { metricValue = actualValue; } - if (d.anomalyScore !== undefined) { + // Check for both an anomaly and for an actual value as anomalies in detectors with + // by and over fields and more than one cause will not have actual / typical values + // at the top level of the anomaly record. + if (d.anomalyScore !== undefined && actualValue !== undefined) { _min = Math.min(_min, metricValue, actualValue, typicalValue); _max = Math.max(_max, metricValue, actualValue, typicalValue); } else { From 4367a10d1b2f12d5f8f891b83ec4563c53d1f03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Wed, 25 Nov 2020 12:44:02 +0100 Subject: [PATCH 59/89] [APM] Rename `ChartsSyncContext` to `PointerEventContext` (#84272) --- .../app/TransactionDetails/index.tsx | 6 +++--- .../components/app/service_metrics/index.tsx | 6 +++--- .../app/service_node_metrics/index.tsx | 10 +++++----- .../components/app/service_overview/index.tsx | 6 +++--- .../shared/charts/timeseries_chart.tsx | 14 ++++++-------- .../transaction_breakdown_chart_contents.tsx | 18 ++++++++++-------- .../shared/charts/transaction_charts/index.tsx | 6 +++--- ...ext.tsx => chart_pointer_event_context.tsx} | 16 +++++++++------- ...ts_sync.tsx => use_chart_pointer_event.tsx} | 8 ++++---- 9 files changed, 46 insertions(+), 44 deletions(-) rename x-pack/plugins/apm/public/context/{charts_sync_context.tsx => chart_pointer_event_context.tsx} (52%) rename x-pack/plugins/apm/public/hooks/{use_charts_sync.tsx => use_chart_pointer_event.tsx} (56%) diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx index 8a99773a97ba..8f335ddc71c7 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx @@ -25,7 +25,7 @@ import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { TransactionDistribution } from './Distribution'; import { WaterfallWithSummmary } from './WaterfallWithSummmary'; import { FETCH_STATUS } from '../../../hooks/useFetcher'; -import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event_context'; import { useTrackPageview } from '../../../../../observability/public'; import { Projection } from '../../../../common/projections'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; @@ -125,13 +125,13 @@ export function TransactionDetails({ - + - + diff --git a/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx index 5dc1645a1760..d0f8fc1e6133 100644 --- a/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_metrics/index.tsx @@ -16,7 +16,7 @@ import React, { useMemo } from 'react'; import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; import { MetricsChart } from '../../shared/charts/metrics_chart'; import { useUrlParams } from '../../../hooks/useUrlParams'; -import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event_context'; import { Projection } from '../../../../common/projections'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { SearchBar } from '../../shared/search_bar'; @@ -57,7 +57,7 @@ export function ServiceMetrics({ - + {data.charts.map((chart) => ( @@ -73,7 +73,7 @@ export function ServiceMetrics({ ))} - + diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx index 59e919199be7..a74ff574bc0c 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx @@ -22,7 +22,7 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; import styled from 'styled-components'; import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; -import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event_context'; import { useAgentName } from '../../../hooks/useAgentName'; import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; @@ -178,7 +178,7 @@ export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { )} {agentName && ( - + {data.charts.map((chart) => ( @@ -194,12 +194,12 @@ export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { ))} - + )} {agentName && ( - + {data.charts.map((chart) => ( @@ -215,7 +215,7 @@ export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { ))} - + )} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 33027f3946d1..ddf3107a8ab1 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -15,7 +15,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; -import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event_context'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink'; @@ -43,7 +43,7 @@ export function ServiceOverview({ useTrackPageview({ app: 'apm', path: 'service_overview', delay: 15000 }); return ( - + @@ -170,6 +170,6 @@ export function ServiceOverview({ - + ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx index fe3d9a1edc1f..c4f5abe104aa 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx @@ -24,7 +24,7 @@ import { useChartTheme } from '../../../../../observability/public'; import { TimeSeries } from '../../../../typings/timeseries'; import { FETCH_STATUS } from '../../../hooks/useFetcher'; import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useChartsSync } from '../../../hooks/use_charts_sync'; +import { useChartPointerEvent } from '../../../hooks/use_chart_pointer_event'; import { unit } from '../../../style/variables'; import { Annotations } from './annotations'; import { ChartContainer } from './chart_container'; @@ -60,15 +60,15 @@ export function TimeseriesChart({ const history = useHistory(); const chartRef = React.createRef(); const chartTheme = useChartTheme(); - const { event, setEvent } = useChartsSync(); + const { pointerEvent, setPointerEvent } = useChartPointerEvent(); const { urlParams } = useUrlParams(); const { start, end } = urlParams; useEffect(() => { - if (event.chartId !== id && chartRef.current) { - chartRef.current.dispatchExternalPointerEvent(event); + if (pointerEvent && pointerEvent?.chartId !== id && chartRef.current) { + chartRef.current.dispatchExternalPointerEvent(pointerEvent); } - }, [event, chartRef, id]); + }, [pointerEvent, chartRef, id]); const min = moment.utc(start).valueOf(); const max = moment.utc(end).valueOf(); @@ -89,9 +89,7 @@ export function TimeseriesChart({ onBrushEnd({ x, history })} theme={chartTheme} - onPointerUpdate={(currEvent: any) => { - setEvent(currEvent); - }} + onPointerUpdate={setPointerEvent} externalPointerEvents={{ tooltip: { visible: true, placement: Placement.Bottom }, }} diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx index 8070868f831b..04c07c01442a 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx @@ -23,7 +23,7 @@ import { asPercent } from '../../../../../common/utils/formatters'; import { TimeSeries } from '../../../../../typings/timeseries'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { useChartsSync as useChartsSync2 } from '../../../../hooks/use_charts_sync'; +import { useChartPointerEvent } from '../../../../hooks/use_chart_pointer_event'; import { unit } from '../../../../style/variables'; import { Annotations } from '../../charts/annotations'; import { ChartContainer } from '../../charts/chart_container'; @@ -45,15 +45,19 @@ export function TransactionBreakdownChartContents({ const history = useHistory(); const chartRef = React.createRef(); const chartTheme = useChartTheme(); - const { event, setEvent } = useChartsSync2(); + const { pointerEvent, setPointerEvent } = useChartPointerEvent(); const { urlParams } = useUrlParams(); const { start, end } = urlParams; useEffect(() => { - if (event.chartId !== 'timeSpentBySpan' && chartRef.current) { - chartRef.current.dispatchExternalPointerEvent(event); + if ( + pointerEvent && + pointerEvent.chartId !== 'timeSpentBySpan' && + chartRef.current + ) { + chartRef.current.dispatchExternalPointerEvent(pointerEvent); } - }, [chartRef, event]); + }, [chartRef, pointerEvent]); const min = moment.utc(start).valueOf(); const max = moment.utc(end).valueOf(); @@ -71,9 +75,7 @@ export function TransactionBreakdownChartContents({ theme={chartTheme} xDomain={{ min, max }} flatLegend - onPointerUpdate={(currEvent: any) => { - setEvent(currEvent); - }} + onPointerUpdate={setPointerEvent} externalPointerEvents={{ tooltip: { visible: true, placement: Placement.Bottom }, }} diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index 3af081c11c9b..221f17bb9e1d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -20,7 +20,7 @@ import { TRANSACTION_ROUTE_CHANGE, } from '../../../../../common/transaction_types'; import { asTransactionRate } from '../../../../../common/utils/formatters'; -import { ChartsSyncContextProvider } from '../../../../context/charts_sync_context'; +import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event_context'; import { LicenseContext } from '../../../../context/LicenseContext'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; @@ -51,7 +51,7 @@ export function TransactionCharts({ return ( <> - + @@ -109,7 +109,7 @@ export function TransactionCharts({ - + ); } diff --git a/x-pack/plugins/apm/public/context/charts_sync_context.tsx b/x-pack/plugins/apm/public/context/chart_pointer_event_context.tsx similarity index 52% rename from x-pack/plugins/apm/public/context/charts_sync_context.tsx rename to x-pack/plugins/apm/public/context/chart_pointer_event_context.tsx index d983a857a26e..ea6020646325 100644 --- a/x-pack/plugins/apm/public/context/charts_sync_context.tsx +++ b/x-pack/plugins/apm/public/context/chart_pointer_event_context.tsx @@ -12,21 +12,23 @@ import React, { useState, } from 'react'; -export const ChartsSyncContext = createContext<{ - event: any; - setEvent: Dispatch>; +import { PointerEvent } from '@elastic/charts'; + +export const ChartPointerEventContext = createContext<{ + pointerEvent: PointerEvent | null; + setPointerEvent: Dispatch>; } | null>(null); -export function ChartsSyncContextProvider({ +export function ChartPointerEventContextProvider({ children, }: { children: ReactNode; }) { - const [event, setEvent] = useState({}); + const [pointerEvent, setPointerEvent] = useState(null); return ( - ); diff --git a/x-pack/plugins/apm/public/hooks/use_charts_sync.tsx b/x-pack/plugins/apm/public/hooks/use_chart_pointer_event.tsx similarity index 56% rename from x-pack/plugins/apm/public/hooks/use_charts_sync.tsx rename to x-pack/plugins/apm/public/hooks/use_chart_pointer_event.tsx index cde5c84a6097..058ec594e2d2 100644 --- a/x-pack/plugins/apm/public/hooks/use_charts_sync.tsx +++ b/x-pack/plugins/apm/public/hooks/use_chart_pointer_event.tsx @@ -5,13 +5,13 @@ */ import { useContext } from 'react'; -import { ChartsSyncContext } from '../context/charts_sync_context'; +import { ChartPointerEventContext } from '../context/chart_pointer_event_context'; -export function useChartsSync() { - const context = useContext(ChartsSyncContext); +export function useChartPointerEvent() { + const context = useContext(ChartPointerEventContext); if (!context) { - throw new Error('Missing ChartsSync context provider'); + throw new Error('Missing ChartPointerEventContext provider'); } return context; From 0f780229b5c195f41e4182f13c3a2d952356a816 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 25 Nov 2020 08:49:17 -0500 Subject: [PATCH 60/89] Put the cluster_uuid in quotes (#83987) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/lib/get_safe_for_external_link.test.ts | 4 ++-- .../public/lib/get_safe_for_external_link.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/monitoring/public/lib/get_safe_for_external_link.test.ts b/x-pack/plugins/monitoring/public/lib/get_safe_for_external_link.test.ts index 4b40c7f4a88d..8c1b9d0c475d 100644 --- a/x-pack/plugins/monitoring/public/lib/get_safe_for_external_link.test.ts +++ b/x-pack/plugins/monitoring/public/lib/get_safe_for_external_link.test.ts @@ -51,7 +51,7 @@ describe('getSafeForExternalLink', () => { location ) ).toBe( - `#/overview?_g=(cluster_uuid:NDKg6VXAT6-TaGzEK2Zy7g,filters:!(),refreshInterval:(pause:!t,value:10000),time:(from:'2017-09-07T20:12:04.011Z',to:'2017-09-07T20:18:55.733Z'))` + `#/overview?_g=(cluster_uuid:'NDKg6VXAT6-TaGzEK2Zy7g',filters:!(),refreshInterval:(pause:!t,value:10000),time:(from:'2017-09-07T20:12:04.011Z',to:'2017-09-07T20:18:55.733Z'))` ); }); @@ -68,7 +68,7 @@ describe('getSafeForExternalLink', () => { location ) ).toBe( - `#/overview?_g=(filters:!(),refreshInterval:(pause:!t,value:10000),time:(from:'2017-09-07T20:12:04.011Z',to:'2017-09-07T20:18:55.733Z'),cluster_uuid:NDKg6VXAT6-TaGzEK2Zy7g)` + `#/overview?_g=(filters:!(),refreshInterval:(pause:!t,value:10000),time:(from:'2017-09-07T20:12:04.011Z',to:'2017-09-07T20:18:55.733Z'),cluster_uuid:'NDKg6VXAT6-TaGzEK2Zy7g')` ); }); }); diff --git a/x-pack/plugins/monitoring/public/lib/get_safe_for_external_link.ts b/x-pack/plugins/monitoring/public/lib/get_safe_for_external_link.ts index 3730ed641122..86d571b87bc9 100644 --- a/x-pack/plugins/monitoring/public/lib/get_safe_for_external_link.ts +++ b/x-pack/plugins/monitoring/public/lib/get_safe_for_external_link.ts @@ -24,15 +24,16 @@ export function getSafeForExternalLink( let newGlobalState = globalStateExecResult[1]; Object.keys(globalState).forEach((globalStateKey) => { + let value = globalState[globalStateKey]; + if (globalStateKey === 'cluster_uuid') { + value = `'${value}'`; + } const keyRegExp = new RegExp(`${globalStateKey}:([^,]+)`); const execResult = keyRegExp.exec(newGlobalState); if (execResult && execResult.length) { - newGlobalState = newGlobalState.replace( - execResult[0], - `${globalStateKey}:${globalState[globalStateKey]}` - ); + newGlobalState = newGlobalState.replace(execResult[0], `${globalStateKey}:${value}`); } else { - newGlobalState += `,${globalStateKey}:${globalState[globalStateKey]}`; + newGlobalState += `,${globalStateKey}:${value}`; } }); From ced1dadb8a5c2dcf50f590dbe3c5db38d512e0c1 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 25 Nov 2020 08:50:03 -0500 Subject: [PATCH 61/89] [Monitoring] Only look at ES for the missing data alert for now (#83839) * Only look at ES for the missing data alert for now * PR feedback * Fix tests --- .../missing_monitoring_data_alert.test.ts | 67 +------ .../fetch_missing_monitoring_data.test.ts | 89 --------- .../alerts/fetch_missing_monitoring_data.ts | 178 +++--------------- 3 files changed, 35 insertions(+), 299 deletions(-) diff --git a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts index 57d01dc6a110..1332148a61cd 100644 --- a/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/missing_monitoring_data_alert.test.ts @@ -65,13 +65,6 @@ describe('MissingMonitoringDataAlert', () => { clusterUuid, gapDuration, }, - { - stackProduct: 'kibana', - stackProductUuid: 'kibanaUuid1', - stackProductName: 'kibanaInstance1', - clusterUuid, - gapDuration: gapDuration + 10, - }, ]; const getUiSettingsService = () => ({ asScopedToClient: jest.fn(), @@ -140,7 +133,7 @@ describe('MissingMonitoringDataAlert', () => { // @ts-ignore params: alert.defaultParams, } as any); - const count = 2; + const count = 1; expect(replaceState).toHaveBeenCalledWith({ alertStates: [ { @@ -187,61 +180,17 @@ describe('MissingMonitoringDataAlert', () => { lastCheckedMS: 0, }, }, - { - ccs: undefined, - cluster: { clusterUuid, clusterName }, - gapDuration: gapDuration + 10, - stackProduct: 'kibana', - stackProductName: 'kibanaInstance1', - stackProductUuid: 'kibanaUuid1', - ui: { - isFiring: true, - message: { - text: - 'For the past an hour, we have not detected any monitoring data from the Kibana instance: kibanaInstance1, starting at #absolute', - nextSteps: [ - { - text: '#start_linkView all Kibana instances#end_link', - tokens: [ - { - startToken: '#start_link', - endToken: '#end_link', - type: 'link', - url: 'kibana/instances', - }, - ], - }, - { - text: 'Verify monitoring settings on the instance', - }, - ], - tokens: [ - { - startToken: '#absolute', - type: 'time', - isAbsolute: true, - isRelative: false, - timestamp: 1, - }, - ], - }, - severity: 'danger', - resolvedMS: 0, - triggeredMS: 1, - lastCheckedMS: 0, - }, - }, ], }); expect(scheduleActions).toHaveBeenCalledWith('default', { - internalFullMessage: `We have not detected any monitoring data for 2 stack product(s) in cluster: testCluster. [View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#/overview?_g=(cluster_uuid:abc123))`, - internalShortMessage: `We have not detected any monitoring data for 2 stack product(s) in cluster: testCluster. Verify these stack products are up and running, then double check the monitoring settings.`, + internalFullMessage: `We have not detected any monitoring data for 1 stack product(s) in cluster: testCluster. [View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#/overview?_g=(cluster_uuid:abc123))`, + internalShortMessage: `We have not detected any monitoring data for 1 stack product(s) in cluster: testCluster. Verify these stack products are up and running, then double check the monitoring settings.`, action: `[View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#/overview?_g=(cluster_uuid:abc123))`, actionPlain: 'Verify these stack products are up and running, then double check the monitoring settings.', clusterName, count, - stackProducts: 'Elasticsearch node: esName1, Kibana instance: kibanaInstance1', + stackProducts: 'Elasticsearch node: esName1', state: 'firing', }); }); @@ -442,16 +391,16 @@ describe('MissingMonitoringDataAlert', () => { // @ts-ignore params: alert.defaultParams, } as any); - const count = 2; + const count = 1; expect(scheduleActions).toHaveBeenCalledWith('default', { - internalFullMessage: `We have not detected any monitoring data for 2 stack product(s) in cluster: testCluster. Verify these stack products are up and running, then double check the monitoring settings.`, - internalShortMessage: `We have not detected any monitoring data for 2 stack product(s) in cluster: testCluster. Verify these stack products are up and running, then double check the monitoring settings.`, + internalFullMessage: `We have not detected any monitoring data for 1 stack product(s) in cluster: testCluster. Verify these stack products are up and running, then double check the monitoring settings.`, + internalShortMessage: `We have not detected any monitoring data for 1 stack product(s) in cluster: testCluster. Verify these stack products are up and running, then double check the monitoring settings.`, action: `[View what monitoring data we do have for these stack products.](http://localhost:5601/app/monitoring#/overview?_g=(cluster_uuid:abc123))`, actionPlain: 'Verify these stack products are up and running, then double check the monitoring settings.', clusterName, count, - stackProducts: 'Elasticsearch node: esName1, Kibana instance: kibanaInstance1', + stackProducts: 'Elasticsearch node: esName1', state: 'firing', }); }); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts index b09f5a88dba9..7edd7496805a 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.test.ts @@ -75,63 +75,6 @@ describe('fetchMissingMonitoringData', () => { timestamp: 2, }, ]), - kibana_uuids: getResponse('.monitoring-kibana-*', [ - { - uuid: 'kibanaUuid1', - nameSource: { - kibana_stats: { - kibana: { - name: 'kibanaName1', - }, - }, - }, - timestamp: 4, - }, - ]), - logstash_uuids: getResponse('.monitoring-logstash-*', [ - { - uuid: 'logstashUuid1', - nameSource: { - logstash_stats: { - logstash: { - host: 'logstashName1', - }, - }, - }, - timestamp: 2, - }, - ]), - beats: { - beats_uuids: getResponse('.monitoring-beats-*', [ - { - uuid: 'beatUuid1', - nameSource: { - beats_stats: { - beat: { - name: 'beatName1', - }, - }, - }, - timestamp: 0, - }, - ]), - }, - apms: { - apm_uuids: getResponse('.monitoring-beats-*', [ - { - uuid: 'apmUuid1', - nameSource: { - beats_stats: { - beat: { - name: 'apmName1', - type: 'apm-server', - }, - }, - }, - timestamp: 1, - }, - ]), - }, })), }, }, @@ -162,38 +105,6 @@ describe('fetchMissingMonitoringData', () => { gapDuration: 8, ccs: null, }, - { - stackProduct: 'kibana', - stackProductUuid: 'kibanaUuid1', - stackProductName: 'kibanaName1', - clusterUuid: 'clusterUuid1', - gapDuration: 6, - ccs: null, - }, - { - stackProduct: 'logstash', - stackProductUuid: 'logstashUuid1', - stackProductName: 'logstashName1', - clusterUuid: 'clusterUuid1', - gapDuration: 8, - ccs: null, - }, - { - stackProduct: 'beats', - stackProductUuid: 'beatUuid1', - stackProductName: 'beatName1', - clusterUuid: 'clusterUuid1', - gapDuration: 10, - ccs: null, - }, - { - stackProduct: 'apm', - stackProductUuid: 'apmUuid1', - stackProductName: 'apmName1', - clusterUuid: 'clusterUuid1', - gapDuration: 9, - ccs: null, - }, ]); }); diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.ts index 0fa90e1d6fb3..b4e12e5d8613 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_missing_monitoring_data.ts @@ -5,25 +5,11 @@ */ import { get } from 'lodash'; import { AlertCluster, AlertMissingData } from '../../../common/types/alerts'; -import { - KIBANA_SYSTEM_ID, - BEATS_SYSTEM_ID, - APM_SYSTEM_ID, - LOGSTASH_SYSTEM_ID, - ELASTICSEARCH_SYSTEM_ID, -} from '../../../common/constants'; +import { ELASTICSEARCH_SYSTEM_ID } from '../../../common/constants'; interface ClusterBucketESResponse { key: string; - kibana_uuids?: UuidResponse; - logstash_uuids?: UuidResponse; - es_uuids?: UuidResponse; - beats?: { - beats_uuids: UuidResponse; - }; - apms?: { - apm_uuids: UuidResponse; - }; + es_uuids: UuidResponse; } interface UuidResponse { @@ -48,44 +34,11 @@ interface TopHitESResponse { source_node?: { name: string; }; - kibana_stats?: { - kibana: { - name: string; - }; - }; - logstash_stats?: { - logstash: { - host: string; - }; - }; - beats_stats?: { - beat: { - name: string; - type: string; - }; - }; }; } -function getStackProductFromIndex(index: string, beatType: string) { - if (index.includes('-kibana-')) { - return KIBANA_SYSTEM_ID; - } - if (index.includes('-beats-')) { - if (beatType === 'apm-server') { - return APM_SYSTEM_ID; - } - return BEATS_SYSTEM_ID; - } - if (index.includes('-logstash-')) { - return LOGSTASH_SYSTEM_ID; - } - if (index.includes('-es-')) { - return ELASTICSEARCH_SYSTEM_ID; - } - return ''; -} - +// TODO: only Elasticsearch until we can figure out how to handle upgrades for the rest of the stack +// https://github.com/elastic/kibana/issues/83309 export async function fetchMissingMonitoringData( callCluster: any, clusters: AlertCluster[], @@ -95,37 +48,6 @@ export async function fetchMissingMonitoringData( startMs: number ): Promise { const endMs = nowInMs; - - const nameFields = [ - 'source_node.name', - 'kibana_stats.kibana.name', - 'logstash_stats.logstash.host', - 'beats_stats.beat.name', - 'beats_stats.beat.type', - ]; - const subAggs = { - most_recent: { - max: { - field: 'timestamp', - }, - }, - document: { - top_hits: { - size: 1, - sort: [ - { - timestamp: { - order: 'desc', - }, - }, - ], - _source: { - includes: ['_index', ...nameFields], - }, - }, - }, - }; - const params = { index, filterPath: ['aggregations.clusters.buckets'], @@ -163,61 +85,28 @@ export async function fetchMissingMonitoringData( field: 'node_stats.node_id', size, }, - aggs: subAggs, - }, - kibana_uuids: { - terms: { - field: 'kibana_stats.kibana.uuid', - size, - }, - aggs: subAggs, - }, - beats: { - filter: { - bool: { - must_not: { - term: { - 'beats_stats.beat.type': 'apm-server', - }, - }, - }, - }, aggs: { - beats_uuids: { - terms: { - field: 'beats_stats.beat.uuid', - size, + most_recent: { + max: { + field: 'timestamp', }, - aggs: subAggs, }, - }, - }, - apms: { - filter: { - bool: { - must: { - term: { - 'beats_stats.beat.type': 'apm-server', + document: { + top_hits: { + size: 1, + sort: [ + { + timestamp: { + order: 'desc', + }, + }, + ], + _source: { + includes: ['_index', 'source_node.name'], }, }, }, }, - aggs: { - apm_uuids: { - terms: { - field: 'beats_stats.beat.uuid', - size, - }, - aggs: subAggs, - }, - }, - }, - logstash_uuids: { - terms: { - field: 'logstash_stats.logstash.uuid', - size, - }, - aggs: subAggs, }, }, }, @@ -234,33 +123,20 @@ export async function fetchMissingMonitoringData( const uniqueList: { [id: string]: AlertMissingData } = {}; for (const clusterBucket of clusterBuckets) { const clusterUuid = clusterBucket.key; - - const uuidBuckets = [ - ...(clusterBucket.es_uuids?.buckets || []), - ...(clusterBucket.kibana_uuids?.buckets || []), - ...(clusterBucket.logstash_uuids?.buckets || []), - ...(clusterBucket.beats?.beats_uuids.buckets || []), - ...(clusterBucket.apms?.apm_uuids.buckets || []), - ]; + const uuidBuckets = clusterBucket.es_uuids.buckets; for (const uuidBucket of uuidBuckets) { const stackProductUuid = uuidBucket.key; const indexName = get(uuidBucket, `document.hits.hits[0]._index`); - const stackProduct = getStackProductFromIndex( - indexName, - get(uuidBucket, `document.hits.hits[0]._source.beats_stats.beat.type`) - ); const differenceInMs = nowInMs - uuidBucket.most_recent.value; - let stackProductName = stackProductUuid; - for (const nameField of nameFields) { - stackProductName = get(uuidBucket, `document.hits.hits[0]._source.${nameField}`); - if (stackProductName) { - break; - } - } + const stackProductName = get( + uuidBucket, + `document.hits.hits[0]._source.source_node.name`, + stackProductUuid + ); - uniqueList[`${clusterUuid}${stackProduct}${stackProductUuid}`] = { - stackProduct, + uniqueList[`${clusterUuid}${stackProductUuid}`] = { + stackProduct: ELASTICSEARCH_SYSTEM_ID, stackProductUuid, stackProductName, clusterUuid, From 5ee0104fe1d400135eda157f535996a22d63dec1 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Wed, 25 Nov 2020 15:20:56 +0100 Subject: [PATCH 62/89] Use .kibana instead of .kibana_current to mark migration completion (#83373) --- rfcs/text/0013_saved_object_migrations.md | 92 ++++++++++++++++------- 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/rfcs/text/0013_saved_object_migrations.md b/rfcs/text/0013_saved_object_migrations.md index 1a0967d110d0..6e125c28c04c 100644 --- a/rfcs/text/0013_saved_object_migrations.md +++ b/rfcs/text/0013_saved_object_migrations.md @@ -13,6 +13,7 @@ - [4.2.1 Idempotent migrations performed without coordination](#421-idempotent-migrations-performed-without-coordination) - [4.2.1.1 Restrictions](#4211-restrictions) - [4.2.1.2 Migration algorithm: Cloned index per version](#4212-migration-algorithm-cloned-index-per-version) + - [Known weaknesses:](#known-weaknesses) - [4.2.1.3 Upgrade and rollback procedure](#4213-upgrade-and-rollback-procedure) - [4.2.1.4 Handling documents that belong to a disabled plugin](#4214-handling-documents-that-belong-to-a-disabled-plugin) - [5. Alternatives](#5-alternatives) @@ -192,26 +193,24 @@ id's deterministically with e.g. UUIDv5. ### 4.2.1.2 Migration algorithm: Cloned index per version Note: - The description below assumes the migration algorithm is released in 7.10.0. - So < 7.10.0 will use `.kibana` and >= 7.10.0 will use `.kibana_current`. + So >= 7.10.0 will use the new algorithm. - We refer to the alias and index that outdated nodes use as the source alias and source index. - Every version performs a migration even if mappings or documents aren't outdated. -1. Locate the source index by fetching aliases (including `.kibana` for - versions prior to v7.10.0) +1. Locate the source index by fetching kibana indices: ``` - GET '/_alias/.kibana_current,.kibana_7.10.0,.kibana' + GET '/_indices/.kibana,.kibana_7.10.0' ``` The source index is: - 1. the index the `.kibana_current` alias points to, or if it doesn’t exist, - 2. the index the `.kibana` alias points to, or if it doesn't exist, - 3. the v6.x `.kibana` index + 1. the index the `.kibana` alias points to, or if it doesn't exist, + 2. the v6.x `.kibana` index If none of the aliases exists, this is a new Elasticsearch cluster and no migrations are necessary. Create the `.kibana_7.10.0_001` index with the - following aliases: `.kibana_current` and `.kibana_7.10.0`. + following aliases: `.kibana` and `.kibana_7.10.0`. 2. If the source is a < v6.5 `.kibana` index or < 7.4 `.kibana_task_manager` index prepare the legacy index for a migration: 1. Mark the legacy index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. @@ -235,13 +234,13 @@ Note: atomically so that other Kibana instances will always see either a `.kibana` index or an alias, but never neither. 6. Use the cloned `.kibana_pre6.5.0_001` as the source for the rest of the migration algorithm. -3. If `.kibana_current` and `.kibana_7.10.0` both exists and are pointing to the same index this version's migration has already been completed. +3. If `.kibana` and `.kibana_7.10.0` both exists and are pointing to the same index this version's migration has already been completed. 1. Because the same version can have plugins enabled at any point in time, perform the mappings update in step (6) and migrate outdated documents with step (7). 2. Skip to step (9) to start serving traffic. 4. Fail the migration if: - 1. `.kibana_current` is pointing to an index that belongs to a later version of Kibana .e.g. `.kibana_7.12.0_001` + 1. `.kibana` is pointing to an index that belongs to a later version of Kibana .e.g. `.kibana_7.12.0_001` 2. (Only in 8.x) The source index contains documents that belong to an unknown Saved Object type (from a disabled plugin). Log an error explaining that the plugin that created these documents needs to be enabled again or that these objects should be deleted. See section (4.2.1.4). 5. Mark the source index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. 6. Clone the source index into a new target index which has writes enabled. All nodes on the same version will use the same fixed index name e.g. `.kibana_7.10.0_001`. The `001` postfix isn't used by Kibana, but allows for re-indexing an index should this be required by an Elasticsearch upgrade. E.g. re-index `.kibana_7.10.0_001` into `.kibana_7.10.0_002` and point the `.kibana_7.10.0` alias to `.kibana_7.10.0_002`. @@ -257,24 +256,62 @@ Note: 8. Transform documents by reading batches of outdated documents from the target index then transforming and updating them with optimistic concurrency control. 1. Ignore any version conflict errors. 2. If a document transform throws an exception, add the document to a failure list and continue trying to transform all other documents. If any failures occured, log the complete list of documents that failed to transform. Fail the migration. -9. Mark the migration as complete by doing a single atomic operation (requires https://github.com/elastic/elasticsearch/pull/58100) that: - 3. Checks that `.kibana_current` alias is still pointing to the source index - 4. Points the `.kibana_7.10.0` and `.kibana_current` aliases to the target index. - 5. If this fails with a "required alias [.kibana_current] does not exist" error fetch `.kibana_current` again: - 1. If `.kibana_current` is _not_ pointing to our target index fail the migration. - 2. If `.kibana_current` is pointing to our target index the migration has succeeded and we can proceed to step (9). -10. Start serving traffic. - -This algorithm shares a weakness with our existing migration algorithm -(since v7.4). When the task manager index gets reindexed a reindex script is -applied. Because we delete the original task manager index there is no way to -rollback a failed task manager migration without a snapshot. +9. Mark the migration as complete. This is done as a single atomic + operation (requires https://github.com/elastic/elasticsearch/pull/58100) + to guarantees when multiple versions of Kibana are performing the + migration in parallel, only one version will win. E.g. if 7.11 and 7.12 + are started in parallel and migrate from a 7.9 index, either 7.11 or 7.12 + should succeed and accept writes, but not both. + 3. Checks that `.kibana` alias is still pointing to the source index + 4. Points the `.kibana_7.10.0` and `.kibana` aliases to the target index. + 5. If this fails with a "required alias [.kibana] does not exist" error fetch `.kibana` again: + 1. If `.kibana` is _not_ pointing to our target index fail the migration. + 2. If `.kibana` is pointing to our target index the migration has succeeded and we can proceed to step (10). +10. Start serving traffic. All saved object reads/writes happen through the + version-specific alias `.kibana_7.10.0`. Together with the limitations, this algorithm ensures that migrations are idempotent. If two nodes are started simultaneously, both of them will start transforming documents in that version's target index, but because migrations are idempotent, it doesn’t matter which node’s writes win. +#### Known weaknesses: +(Also present in our existing migration algorithm since v7.4) +When the task manager index gets reindexed a reindex script is applied. +Because we delete the original task manager index there is no way to rollback +a failed task manager migration without a snapshot. Although losing the task +manager data has a fairly low impact. + +(Also present in our existing migration algorithm since v6.5) +If the outdated instance isn't shutdown before starting the migration, the +following data-loss scenario is possible: +1. Upgrade a 7.9 index without shutting down the 7.9 nodes +2. Kibana v7.10 performs a migration and after completing points `.kibana` + alias to `.kibana_7.11.0_001` +3. Kibana v7.9 writes unmigrated documents into `.kibana`. +4. Kibana v7.10 performs a query based on the updated mappings of documents so + results potentially don't match the acknowledged write from step (3). + +Note: + - Data loss won't occur if both nodes have the updated migration algorithm + proposed in this RFC. It is only when one of the nodes use the existing + algorithm that data loss is possible. + - Once v7.10 is restarted it will transform any outdated documents making + these visible to queries again. + +It is possible to work around this weakness by introducing a new alias such as +`.kibana_current` so that after a migration the `.kibana` alias will continue +to point to the outdated index. However, we decided to keep using the +`.kibana` alias despite this weakness for the following reasons: + - Users might rely on `.kibana` alias for snapshots, so if this alias no + longer points to the latest index their snapshots would no longer backup + kibana's latest data. + - Introducing another alias introduces complexity for users and support. + The steps to diagnose, fix or rollback a failed migration will deviate + depending on the 7.x version of Kibana you are using. + - The existing Kibana documentation clearly states that outdated nodes should + be shutdown, this scenario has never been supported by Kibana. +
    In the future, this algorithm could enable (2.6) "read-only functionality during the downtime window" but this is outside of the scope of this RFC. @@ -303,12 +340,9 @@ To rollback to a previous version of Kibana without a snapshot: (Assumes the migration to 7.11.0 failed) 1. Shutdown all Kibana nodes. 2. Remove the index created by the failed Kibana migration by using the version-specific alias e.g. `DELETE /.kibana_7.11.0` -3. Identify the rollback index: - 1. If rolling back to a Kibana version < 7.10.0 use `.kibana` - 2. If rolling back to a Kibana version >= 7.10.0 use the version alias of the Kibana version you wish to rollback to e.g. `.kibana_7.10.0` -4. Point the `.kibana_current` alias to the rollback index. -5. Remove the write block from the rollback index. -6. Start the rollback Kibana nodes. All running Kibana nodes should be on the same rollback version, have the same plugins enabled and use the same configuration. +3. Remove the write block from the rollback index using the `.kibana` alias + `PUT /.kibana/_settings {"index.blocks.write": false}` +4. Start the rollback Kibana nodes. All running Kibana nodes should be on the same rollback version, have the same plugins enabled and use the same configuration. ### 4.2.1.4 Handling documents that belong to a disabled plugin It is possible for a plugin to create documents in one version of Kibana, but then when upgrading Kibana to a newer version, that plugin is disabled. Because the plugin is disabled it cannot register it's Saved Objects type including the mappings or any migration transformation functions. These "orphan" documents could cause future problems: @@ -378,7 +412,7 @@ There are several approaches we could take to dealing with these orphan document deterministically perform the delete and re-clone operation without coordination. -5. Transform outdated documents (step 7) on every startup +5. Transform outdated documents (step 8) on every startup Advantages: - Outdated documents belonging to disabled plugins will be upgraded as soon as the plugin is enabled again. From b3430e3f09deac0ed3e577d4f27ef7f9865f2b01 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Wed, 25 Nov 2020 16:32:05 +0200 Subject: [PATCH 63/89] [Search] Search batching using bfetch (again) (#84043) Re-merging after cypress fixes --- ...plugin-plugins-data-public.plugin.setup.md | 4 +- ...ata-public.searchinterceptordeps.bfetch.md | 11 ++ ...ugins-data-public.searchinterceptordeps.md | 1 + ...plugin-plugins-data-server.plugin.setup.md | 4 +- .../create_streaming_batched_function.test.ts | 127 +++++++++++++++++- .../create_streaming_batched_function.ts | 87 ++++++++---- src/plugins/bfetch/public/batching/types.ts | 31 +++++ src/plugins/bfetch/public/index.ts | 2 + src/plugins/bfetch/public/plugin.ts | 2 +- .../public/streaming/fetch_streaming.test.ts | 27 ++++ .../public/streaming/fetch_streaming.ts | 4 +- .../streaming/from_streaming_xhr.test.ts | 34 +++++ .../public/streaming/from_streaming_xhr.ts | 22 ++- src/plugins/data/kibana.json | 1 + src/plugins/data/public/plugin.ts | 3 +- src/plugins/data/public/public.api.md | 5 +- .../public/search/search_interceptor.test.ts | 26 ++-- .../data/public/search/search_interceptor.ts | 37 +++-- .../data/public/search/search_service.test.ts | 3 + .../data/public/search/search_service.ts | 5 +- src/plugins/data/public/types.ts | 2 + src/plugins/data/server/plugin.ts | 5 +- .../data/server/search/search_service.test.ts | 18 ++- .../data/server/search/search_service.ts | 56 +++++++- src/plugins/data/server/server.api.md | 5 +- src/plugins/data/server/ui_settings.ts | 7 +- src/plugins/embeddable/public/public.api.md | 1 + x-pack/plugins/data_enhanced/kibana.json | 1 + x-pack/plugins/data_enhanced/public/plugin.ts | 5 +- .../public/search/search_interceptor.test.ts | 25 ++-- .../security_solution/cypress/cypress.json | 1 + .../fixtures/overview_search_strategy.json | 6 +- .../cypress/integration/alerts.spec.ts | 14 +- .../alerts_detection_rules.spec.ts | 3 +- .../alerts_detection_rules_custom.spec.ts | 2 +- .../alerts_detection_rules_export.spec.ts | 3 +- .../cypress/integration/overview.spec.ts | 16 +-- .../cypress/support/commands.js | 70 ++++++++-- .../cypress/support/index.d.ts | 7 +- .../es_archives/timeline_alerts/mappings.json | 5 +- 40 files changed, 562 insertions(+), 126 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md create mode 100644 src/plugins/bfetch/public/batching/types.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md index a0c9b3879282..1ed6059c2306 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md @@ -7,7 +7,7 @@ Signature: ```typescript -setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; +setup(core: CoreSetup, { bfetch, expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; ``` ## Parameters @@ -15,7 +15,7 @@ setup(core: CoreSetup, { expressio | Parameter | Type | Description | | --- | --- | --- | | core | CoreSetup<DataStartDependencies, DataPublicPluginStart> | | -| { expressions, uiActions, usageCollection } | DataSetupDependencies | | +| { bfetch, expressions, uiActions, usageCollection } | DataSetupDependencies | | Returns: diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md new file mode 100644 index 000000000000..5b7c635c7152 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptorDeps](./kibana-plugin-plugins-data-public.searchinterceptordeps.md) > [bfetch](./kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md) + +## SearchInterceptorDeps.bfetch property + +Signature: + +```typescript +bfetch: BfetchPublicSetup; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md index 3653394d28b9..543566b783c2 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptordeps.md @@ -14,6 +14,7 @@ export interface SearchInterceptorDeps | Property | Type | Description | | --- | --- | --- | +| [bfetch](./kibana-plugin-plugins-data-public.searchinterceptordeps.bfetch.md) | BfetchPublicSetup | | | [http](./kibana-plugin-plugins-data-public.searchinterceptordeps.http.md) | CoreSetup['http'] | | | [session](./kibana-plugin-plugins-data-public.searchinterceptordeps.session.md) | ISessionService | | | [startServices](./kibana-plugin-plugins-data-public.searchinterceptordeps.startservices.md) | Promise<[CoreStart, any, unknown]> | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md index 43129891c541..b90018c3d9cd 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.setup.md @@ -7,7 +7,7 @@ Signature: ```typescript -setup(core: CoreSetup, { expressions, usageCollection }: DataPluginSetupDependencies): { +setup(core: CoreSetup, { bfetch, expressions, usageCollection }: DataPluginSetupDependencies): { __enhance: (enhancements: DataEnhancements) => void; search: ISearchSetup; fieldFormats: { @@ -21,7 +21,7 @@ setup(core: CoreSetup, { expressio | Parameter | Type | Description | | --- | --- | --- | | core | CoreSetup<DataPluginStartDependencies, DataPluginStart> | | -| { expressions, usageCollection } | DataPluginSetupDependencies | | +| { bfetch, expressions, usageCollection } | DataPluginSetupDependencies | | Returns: diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts index d7dde8f1b93d..3498f205b328 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts @@ -19,7 +19,7 @@ import { createStreamingBatchedFunction } from './create_streaming_batched_function'; import { fetchStreaming as fetchStreamingReal } from '../streaming/fetch_streaming'; -import { defer, of } from '../../../kibana_utils/public'; +import { AbortError, defer, of } from '../../../kibana_utils/public'; import { Subject } from 'rxjs'; const getPromiseState = (promise: Promise): Promise<'resolved' | 'rejected' | 'pending'> => @@ -168,6 +168,28 @@ describe('createStreamingBatchedFunction()', () => { expect(fetchStreaming).toHaveBeenCalledTimes(1); }); + test('ignores a request with an aborted signal', async () => { + const { fetchStreaming } = setup(); + const fn = createStreamingBatchedFunction({ + url: '/test', + fetchStreaming, + maxItemAge: 5, + flushOnMaxItems: 3, + }); + + const abortController = new AbortController(); + abortController.abort(); + + of(fn({ foo: 'bar' }, abortController.signal)); + fn({ baz: 'quix' }); + + await new Promise((r) => setTimeout(r, 6)); + const { body } = fetchStreaming.mock.calls[0][0]; + expect(JSON.parse(body)).toEqual({ + batch: [{ baz: 'quix' }], + }); + }); + test('sends POST request to correct endpoint with items in array batched sorted in call order', async () => { const { fetchStreaming } = setup(); const fn = createStreamingBatchedFunction({ @@ -423,6 +445,73 @@ describe('createStreamingBatchedFunction()', () => { expect(result3).toEqual({ b: '3' }); }); + describe('when requests are aborted', () => { + test('aborts stream when all are aborted', async () => { + const { fetchStreaming } = setup(); + const fn = createStreamingBatchedFunction({ + url: '/test', + fetchStreaming, + maxItemAge: 5, + flushOnMaxItems: 3, + }); + + const abortController = new AbortController(); + const promise = fn({ a: '1' }, abortController.signal); + const promise2 = fn({ a: '2' }, abortController.signal); + await new Promise((r) => setTimeout(r, 6)); + + expect(await isPending(promise)).toBe(true); + expect(await isPending(promise2)).toBe(true); + + abortController.abort(); + await new Promise((r) => setTimeout(r, 6)); + + expect(await isPending(promise)).toBe(false); + expect(await isPending(promise2)).toBe(false); + const [, error] = await of(promise); + const [, error2] = await of(promise2); + expect(error).toBeInstanceOf(AbortError); + expect(error2).toBeInstanceOf(AbortError); + expect(fetchStreaming.mock.calls[0][0].signal.aborted).toBeTruthy(); + }); + + test('rejects promise on abort and lets others continue', async () => { + const { fetchStreaming, stream } = setup(); + const fn = createStreamingBatchedFunction({ + url: '/test', + fetchStreaming, + maxItemAge: 5, + flushOnMaxItems: 3, + }); + + const abortController = new AbortController(); + const promise = fn({ a: '1' }, abortController.signal); + const promise2 = fn({ a: '2' }); + await new Promise((r) => setTimeout(r, 6)); + + expect(await isPending(promise)).toBe(true); + + abortController.abort(); + await new Promise((r) => setTimeout(r, 6)); + + expect(await isPending(promise)).toBe(false); + const [, error] = await of(promise); + expect(error).toBeInstanceOf(AbortError); + + stream.next( + JSON.stringify({ + id: 1, + result: { b: '2' }, + }) + '\n' + ); + + await new Promise((r) => setTimeout(r, 1)); + + const [result2] = await of(promise2); + expect(result2).toEqual({ b: '2' }); + }); + }); + describe('when stream closes prematurely', () => { test('rejects pending promises with CONNECTION error code', async () => { const { fetchStreaming, stream } = setup(); @@ -558,5 +647,41 @@ describe('createStreamingBatchedFunction()', () => { }); }); }); + + test('rejects with STREAM error on JSON parse error only pending promises', async () => { + const { fetchStreaming, stream } = setup(); + const fn = createStreamingBatchedFunction({ + url: '/test', + fetchStreaming, + maxItemAge: 5, + flushOnMaxItems: 3, + }); + + const promise1 = of(fn({ a: '1' })); + const promise2 = of(fn({ a: '2' })); + + await new Promise((r) => setTimeout(r, 6)); + + stream.next( + JSON.stringify({ + id: 1, + result: { b: '1' }, + }) + '\n' + ); + + stream.next('Not a JSON\n'); + + await new Promise((r) => setTimeout(r, 1)); + + const [, error1] = await promise1; + const [result1] = await promise2; + expect(error1).toMatchObject({ + message: 'Unexpected token N in JSON at position 0', + code: 'STREAM', + }); + expect(result1).toMatchObject({ + b: '1', + }); + }); }); }); diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts index 89793fff6b32..f3971ed04efa 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts @@ -17,7 +17,7 @@ * under the License. */ -import { defer, Defer } from '../../../kibana_utils/public'; +import { AbortError, abortSignalToPromise, defer } from '../../../kibana_utils/public'; import { ItemBufferParams, TimedItemBufferParams, @@ -27,13 +27,7 @@ import { } from '../../common'; import { fetchStreaming, split } from '../streaming'; import { normalizeError } from '../../common'; - -export interface BatchItem { - payload: Payload; - future: Defer; -} - -export type BatchedFunc = (payload: Payload) => Promise; +import { BatchedFunc, BatchItem } from './types'; export interface BatchedFunctionProtocolError extends ErrorLike { code: string; @@ -82,43 +76,84 @@ export const createStreamingBatchedFunction = ( flushOnMaxItems = 25, maxItemAge = 10, } = params; - const [fn] = createBatchedFunction, BatchItem>({ - onCall: (payload: Payload) => { + const [fn] = createBatchedFunction({ + onCall: (payload: Payload, signal?: AbortSignal) => { const future = defer(); const entry: BatchItem = { payload, future, + signal, }; return [future.promise, entry]; }, onBatch: async (items) => { try { - let responsesReceived = 0; - const batch = items.map(({ payload }) => payload); + // Filter out any items whose signal is already aborted + items = items.filter((item) => { + if (item.signal?.aborted) item.future.reject(new AbortError()); + return !item.signal?.aborted; + }); + + const donePromises: Array> = items.map((item) => { + return new Promise((resolve) => { + const { promise: abortPromise, cleanup } = item.signal + ? abortSignalToPromise(item.signal) + : { + promise: undefined, + cleanup: () => {}, + }; + + const onDone = () => { + resolve(); + cleanup(); + }; + if (abortPromise) + abortPromise.catch(() => { + item.future.reject(new AbortError()); + onDone(); + }); + item.future.promise.then(onDone, onDone); + }); + }); + + // abort when all items were either resolved, rejected or aborted + const abortController = new AbortController(); + let isBatchDone = false; + Promise.all(donePromises).then(() => { + isBatchDone = true; + abortController.abort(); + }); + const batch = items.map((item) => item.payload); + const { stream } = fetchStreamingInjected({ url, body: JSON.stringify({ batch }), method: 'POST', + signal: abortController.signal, }); + + const handleStreamError = (error: any) => { + const normalizedError = normalizeError(error); + normalizedError.code = 'STREAM'; + for (const { future } of items) future.reject(normalizedError); + }; + stream.pipe(split('\n')).subscribe({ next: (json: string) => { - const response = JSON.parse(json) as BatchResponseItem; - if (response.error) { - responsesReceived++; - items[response.id].future.reject(response.error); - } else if (response.result !== undefined) { - responsesReceived++; - items[response.id].future.resolve(response.result); + try { + const response = JSON.parse(json) as BatchResponseItem; + if (response.error) { + items[response.id].future.reject(response.error); + } else if (response.result !== undefined) { + items[response.id].future.resolve(response.result); + } + } catch (e) { + handleStreamError(e); } }, - error: (error) => { - const normalizedError = normalizeError(error); - normalizedError.code = 'STREAM'; - for (const { future } of items) future.reject(normalizedError); - }, + error: handleStreamError, complete: () => { - const streamTerminatedPrematurely = responsesReceived !== items.length; - if (streamTerminatedPrematurely) { + if (!isBatchDone) { const error: BatchedFunctionProtocolError = { message: 'Connection terminated prematurely.', code: 'CONNECTION', diff --git a/src/plugins/bfetch/public/batching/types.ts b/src/plugins/bfetch/public/batching/types.ts new file mode 100644 index 000000000000..68860c5d9eed --- /dev/null +++ b/src/plugins/bfetch/public/batching/types.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Defer } from '../../../kibana_utils/public'; + +export interface BatchItem { + payload: Payload; + future: Defer; + signal?: AbortSignal; +} + +export type BatchedFunc = ( + payload: Payload, + signal?: AbortSignal +) => Promise; diff --git a/src/plugins/bfetch/public/index.ts b/src/plugins/bfetch/public/index.ts index 8707e5a43815..7ff110105faa 100644 --- a/src/plugins/bfetch/public/index.ts +++ b/src/plugins/bfetch/public/index.ts @@ -23,6 +23,8 @@ import { BfetchPublicPlugin } from './plugin'; export { BfetchPublicSetup, BfetchPublicStart, BfetchPublicContract } from './plugin'; export { split } from './streaming'; +export { BatchedFunc } from './batching/types'; + export function plugin(initializerContext: PluginInitializerContext) { return new BfetchPublicPlugin(initializerContext); } diff --git a/src/plugins/bfetch/public/plugin.ts b/src/plugins/bfetch/public/plugin.ts index 5f01957c0908..72aaa862b0ad 100644 --- a/src/plugins/bfetch/public/plugin.ts +++ b/src/plugins/bfetch/public/plugin.ts @@ -22,9 +22,9 @@ import { fetchStreaming as fetchStreamingStatic, FetchStreamingParams } from './ import { removeLeadingSlash } from '../common'; import { createStreamingBatchedFunction, - BatchedFunc, StreamingBatchedFunctionParams, } from './batching/create_streaming_batched_function'; +import { BatchedFunc } from './batching/types'; // eslint-disable-next-line export interface BfetchPublicSetupDependencies {} diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts index 27adc6dc8b54..7a6827b8fee8 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts @@ -132,6 +132,33 @@ test('completes stream observable when request finishes', async () => { expect(spy).toHaveBeenCalledTimes(1); }); +test('completes stream observable when aborted', async () => { + const env = setup(); + const abort = new AbortController(); + const { stream } = fetchStreaming({ + url: 'http://example.com', + signal: abort.signal, + }); + + const spy = jest.fn(); + stream.subscribe({ + complete: spy, + }); + + expect(spy).toHaveBeenCalledTimes(0); + + (env.xhr as any).responseText = 'foo'; + env.xhr.onprogress!({} as any); + + abort.abort(); + + (env.xhr as any).readyState = 4; + (env.xhr as any).status = 200; + env.xhr.onreadystatechange!({} as any); + + expect(spy).toHaveBeenCalledTimes(1); +}); + test('promise throws when request errors', async () => { const env = setup(); const { stream } = fetchStreaming({ diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.ts index 899e8a1824a4..3deee0cf66ad 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.ts @@ -24,6 +24,7 @@ export interface FetchStreamingParams { headers?: Record; method?: 'GET' | 'POST'; body?: string; + signal?: AbortSignal; } /** @@ -35,6 +36,7 @@ export function fetchStreaming({ headers = {}, method = 'POST', body = '', + signal, }: FetchStreamingParams) { const xhr = new window.XMLHttpRequest(); @@ -45,7 +47,7 @@ export function fetchStreaming({ // Set the HTTP headers Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); - const stream = fromStreamingXhr(xhr); + const stream = fromStreamingXhr(xhr, signal); // Send the payload to the server xhr.send(body); diff --git a/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts b/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts index 40eb3d5e2556..b15bf9bdfbbb 100644 --- a/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts +++ b/src/plugins/bfetch/public/streaming/from_streaming_xhr.test.ts @@ -21,6 +21,7 @@ import { fromStreamingXhr } from './from_streaming_xhr'; const createXhr = (): XMLHttpRequest => (({ + abort: () => {}, onprogress: () => {}, onreadystatechange: () => {}, readyState: 0, @@ -100,6 +101,39 @@ test('completes observable when request reaches end state', () => { expect(complete).toHaveBeenCalledTimes(1); }); +test('completes observable when aborted', () => { + const xhr = createXhr(); + const abortController = new AbortController(); + const observable = fromStreamingXhr(xhr, abortController.signal); + + const next = jest.fn(); + const complete = jest.fn(); + observable.subscribe({ + next, + complete, + }); + + (xhr as any).responseText = '1'; + xhr.onprogress!({} as any); + + (xhr as any).responseText = '2'; + xhr.onprogress!({} as any); + + expect(complete).toHaveBeenCalledTimes(0); + + (xhr as any).readyState = 2; + abortController.abort(); + + expect(complete).toHaveBeenCalledTimes(1); + + // Shouldn't trigger additional events + (xhr as any).readyState = 4; + (xhr as any).status = 200; + xhr.onreadystatechange!({} as any); + + expect(complete).toHaveBeenCalledTimes(1); +}); + test('errors observable if request returns with error', () => { const xhr = createXhr(); const observable = fromStreamingXhr(xhr); diff --git a/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts b/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts index bba815195849..5df1f5258cb2 100644 --- a/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts +++ b/src/plugins/bfetch/public/streaming/from_streaming_xhr.ts @@ -26,13 +26,17 @@ import { Observable, Subject } from 'rxjs'; export const fromStreamingXhr = ( xhr: Pick< XMLHttpRequest, - 'onprogress' | 'onreadystatechange' | 'readyState' | 'status' | 'responseText' - > + 'onprogress' | 'onreadystatechange' | 'readyState' | 'status' | 'responseText' | 'abort' + >, + signal?: AbortSignal ): Observable => { const subject = new Subject(); let index = 0; + let aborted = false; const processBatch = () => { + if (aborted) return; + const { responseText } = xhr; if (index >= responseText.length) return; subject.next(responseText.substr(index)); @@ -41,7 +45,19 @@ export const fromStreamingXhr = ( xhr.onprogress = processBatch; + const onBatchAbort = () => { + if (xhr.readyState !== 4) { + aborted = true; + xhr.abort(); + subject.complete(); + if (signal) signal.removeEventListener('abort', onBatchAbort); + } + }; + + if (signal) signal.addEventListener('abort', onBatchAbort); + xhr.onreadystatechange = () => { + if (aborted) return; // Older browsers don't support onprogress, so we need // to call this here, too. It's safe to call this multiple // times even for the same progress event. @@ -49,6 +65,8 @@ export const fromStreamingXhr = ( // 4 is the magic number that means the request is done if (xhr.readyState === 4) { + if (signal) signal.removeEventListener('abort', onBatchAbort); + // 0 indicates a network failure. 400+ messages are considered server errors if (xhr.status === 0 || xhr.status >= 400) { subject.error(new Error(`Batch request failed with status ${xhr.status}`)); diff --git a/src/plugins/data/kibana.json b/src/plugins/data/kibana.json index d6f2534bd5e3..3e4d08c8faa1 100644 --- a/src/plugins/data/kibana.json +++ b/src/plugins/data/kibana.json @@ -4,6 +4,7 @@ "server": true, "ui": true, "requiredPlugins": [ + "bfetch", "expressions", "uiActions" ], diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 7e8283476ffc..dded52310a99 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -105,7 +105,7 @@ export class DataPublicPlugin public setup( core: CoreSetup, - { expressions, uiActions, usageCollection }: DataSetupDependencies + { bfetch, expressions, uiActions, usageCollection }: DataSetupDependencies ): DataPublicPluginSetup { const startServices = createStartServicesGetter(core.getStartServices); @@ -152,6 +152,7 @@ export class DataPublicPlugin ); const searchService = this.searchService.setup(core, { + bfetch, usageCollection, expressions, }); diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 7c4d3ee27cf4..a6daaf834a42 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -12,6 +12,7 @@ import { ApiResponse as ApiResponse_2 } from '@elastic/elasticsearch/lib/Transpo import { ApplicationStart } from 'kibana/public'; import { Assign } from '@kbn/utility-types'; import { BehaviorSubject } from 'rxjs'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import Boom from '@hapi/boom'; import { CoreSetup } from 'src/core/public'; import { CoreSetup as CoreSetup_2 } from 'kibana/public'; @@ -1734,7 +1735,7 @@ export class Plugin implements Plugin_2); // (undocumented) - setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; + setup(core: CoreSetup, { bfetch, expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; // (undocumented) start(core: CoreStart_2, { uiActions }: DataStartDependencies): DataPublicPluginStart; // (undocumented) @@ -2113,6 +2114,8 @@ export class SearchInterceptor { // // @public (undocumented) export interface SearchInterceptorDeps { + // (undocumented) + bfetch: BfetchPublicSetup; // (undocumented) http: CoreSetup_2['http']; // (undocumented) diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index 6e75f6e5eef9..6dc52d701679 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -25,9 +25,13 @@ import { AbortError } from '../../../kibana_utils/public'; import { SearchTimeoutError, PainlessError, TimeoutErrorMode } from './errors'; import { searchServiceMock } from './mocks'; import { ISearchStart } from '.'; +import { bfetchPluginMock } from '../../../bfetch/public/mocks'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; let searchInterceptor: SearchInterceptor; let mockCoreSetup: MockedKeys; +let bfetchSetup: jest.Mocked; +let fetchMock: jest.Mock; const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); jest.useFakeTimers(); @@ -39,7 +43,11 @@ describe('SearchInterceptor', () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); searchMock = searchServiceMock.createStartContract(); + fetchMock = jest.fn(); + bfetchSetup = bfetchPluginMock.createSetupContract(); + bfetchSetup.batchedFunction.mockReturnValue(fetchMock); searchInterceptor = new SearchInterceptor({ + bfetch: bfetchSetup, toasts: mockCoreSetup.notifications.toasts, startServices: new Promise((resolve) => { resolve([mockCoreStart, {}, {}]); @@ -91,7 +99,7 @@ describe('SearchInterceptor', () => { describe('search', () => { test('Observable should resolve if fetch is successful', async () => { const mockResponse: any = { result: 200 }; - mockCoreSetup.http.fetch.mockResolvedValueOnce(mockResponse); + fetchMock.mockResolvedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -102,7 +110,7 @@ describe('SearchInterceptor', () => { describe('Should throw typed errors', () => { test('Observable should fail if fetch has an internal error', async () => { const mockResponse: any = new Error('Internal Error'); - mockCoreSetup.http.fetch.mockRejectedValue(mockResponse); + fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -118,7 +126,7 @@ describe('SearchInterceptor', () => { message: 'Request timed out', }, }; - mockCoreSetup.http.fetch.mockRejectedValueOnce(mockResponse); + fetchMock.mockRejectedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -134,7 +142,7 @@ describe('SearchInterceptor', () => { message: 'Request timed out', }, }; - mockCoreSetup.http.fetch.mockRejectedValue(mockResponse); + fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -155,7 +163,7 @@ describe('SearchInterceptor', () => { message: 'Request timed out', }, }; - mockCoreSetup.http.fetch.mockRejectedValue(mockResponse); + fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -176,7 +184,7 @@ describe('SearchInterceptor', () => { message: 'Request timed out', }, }; - mockCoreSetup.http.fetch.mockRejectedValue(mockResponse); + fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -209,7 +217,7 @@ describe('SearchInterceptor', () => { }, }, }; - mockCoreSetup.http.fetch.mockRejectedValueOnce(mockResponse); + fetchMock.mockRejectedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { params: {}, }; @@ -219,7 +227,7 @@ describe('SearchInterceptor', () => { test('Observable should fail if user aborts (test merged signal)', async () => { const abortController = new AbortController(); - mockCoreSetup.http.fetch.mockImplementationOnce((options: any) => { + fetchMock.mockImplementationOnce((options: any) => { return new Promise((resolve, reject) => { options.signal.addEventListener('abort', () => { reject(new AbortError()); @@ -257,7 +265,7 @@ describe('SearchInterceptor', () => { const error = (e: any) => { expect(e).toBeInstanceOf(AbortError); - expect(mockCoreSetup.http.fetch).not.toBeCalled(); + expect(fetchMock).not.toBeCalled(); done(); }; response.subscribe({ error }); diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index e5abac0d48fe..055b3a71705b 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -17,17 +17,17 @@ * under the License. */ -import { get, memoize, trimEnd } from 'lodash'; +import { get, memoize } from 'lodash'; import { BehaviorSubject, throwError, timer, defer, from, Observable, NEVER } from 'rxjs'; import { catchError, finalize } from 'rxjs/operators'; import { PublicMethodsOf } from '@kbn/utility-types'; import { CoreStart, CoreSetup, ToastsSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; +import { BatchedFunc, BfetchPublicSetup } from 'src/plugins/bfetch/public'; import { IKibanaSearchRequest, IKibanaSearchResponse, ISearchOptions, - ES_SEARCH_STRATEGY, ISessionService, } from '../../common'; import { SearchUsageCollector } from './collectors'; @@ -44,6 +44,7 @@ import { toMountPoint } from '../../../kibana_react/public'; import { AbortError, getCombinedAbortSignal } from '../../../kibana_utils/public'; export interface SearchInterceptorDeps { + bfetch: BfetchPublicSetup; http: CoreSetup['http']; uiSettings: CoreSetup['uiSettings']; startServices: Promise<[CoreStart, any, unknown]>; @@ -69,6 +70,10 @@ export class SearchInterceptor { * @internal */ protected application!: CoreStart['application']; + private batchedFetch!: BatchedFunc< + { request: IKibanaSearchRequest; options: ISearchOptions }, + IKibanaSearchResponse + >; /* * @internal @@ -79,6 +84,10 @@ export class SearchInterceptor { this.deps.startServices.then(([coreStart]) => { this.application = coreStart.application; }); + + this.batchedFetch = deps.bfetch.batchedFunction({ + url: '/internal/bsearch', + }); } /* @@ -123,24 +132,14 @@ export class SearchInterceptor { request: IKibanaSearchRequest, options?: ISearchOptions ): Promise { - const { id, ...searchRequest } = request; - const path = trimEnd( - `/internal/search/${options?.strategy ?? ES_SEARCH_STRATEGY}/${id ?? ''}`, - '/' + const { abortSignal, ...requestOptions } = options || {}; + return this.batchedFetch( + { + request, + options: requestOptions, + }, + abortSignal ); - const body = JSON.stringify({ - sessionId: options?.sessionId, - isStored: options?.isStored, - isRestore: options?.isRestore, - ...searchRequest, - }); - - return this.deps.http.fetch({ - method: 'POST', - path, - body, - signal: options?.abortSignal, - }); } /** diff --git a/src/plugins/data/public/search/search_service.test.ts b/src/plugins/data/public/search/search_service.test.ts index 20041a02067d..3179da4d03a1 100644 --- a/src/plugins/data/public/search/search_service.test.ts +++ b/src/plugins/data/public/search/search_service.test.ts @@ -21,6 +21,7 @@ import { coreMock } from '../../../../core/public/mocks'; import { CoreSetup, CoreStart } from '../../../../core/public'; import { SearchService, SearchServiceSetupDependencies } from './search_service'; +import { bfetchPluginMock } from '../../../bfetch/public/mocks'; describe('Search service', () => { let searchService: SearchService; @@ -39,8 +40,10 @@ describe('Search service', () => { describe('setup()', () => { it('exposes proper contract', async () => { + const bfetch = bfetchPluginMock.createSetupContract(); const setup = searchService.setup(mockCoreSetup, ({ packageInfo: { version: '8' }, + bfetch, expressions: { registerFunction: jest.fn(), registerType: jest.fn() }, } as unknown) as SearchServiceSetupDependencies); expect(setup).toHaveProperty('aggs'); diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 96fb3f91ea85..b76b5846d3d9 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -19,6 +19,7 @@ import { Plugin, CoreSetup, CoreStart, PluginInitializerContext } from 'src/core/public'; import { BehaviorSubject } from 'rxjs'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import { ISearchSetup, ISearchStart, SearchEnhancements } from './types'; import { handleResponse } from './fetch'; @@ -49,6 +50,7 @@ import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn'; /** @internal */ export interface SearchServiceSetupDependencies { + bfetch: BfetchPublicSetup; expressions: ExpressionsSetup; usageCollection?: UsageCollectionSetup; } @@ -70,7 +72,7 @@ export class SearchService implements Plugin { public setup( { http, getStartServices, notifications, uiSettings }: CoreSetup, - { expressions, usageCollection }: SearchServiceSetupDependencies + { bfetch, expressions, usageCollection }: SearchServiceSetupDependencies ): ISearchSetup { this.usageCollector = createUsageCollector(getStartServices, usageCollection); @@ -80,6 +82,7 @@ export class SearchService implements Plugin { * all pending search requests, as well as getting the number of pending search requests. */ this.searchInterceptor = new SearchInterceptor({ + bfetch, toasts: notifications.toasts, http, uiSettings, diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index 21a03a49fe05..4082fbe55094 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -19,6 +19,7 @@ import React from 'react'; import { CoreStart } from 'src/core/public'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { ExpressionsSetup } from 'src/plugins/expressions/public'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; @@ -36,6 +37,7 @@ export interface DataPublicPluginEnhancements { } export interface DataSetupDependencies { + bfetch: BfetchPublicSetup; expressions: ExpressionsSetup; uiActions: UiActionsSetup; usageCollection?: UsageCollectionSetup; diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index 3ec4e7e64e38..bba2c368ff7d 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -19,6 +19,7 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from 'src/core/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ConfigSchema } from '../config'; import { IndexPatternsService, IndexPatternsServiceStart } from './index_patterns'; import { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; @@ -51,6 +52,7 @@ export interface DataPluginStart { } export interface DataPluginSetupDependencies { + bfetch: BfetchServerSetup; expressions: ExpressionsServerSetup; usageCollection?: UsageCollectionSetup; } @@ -85,7 +87,7 @@ export class DataServerPlugin public setup( core: CoreSetup, - { expressions, usageCollection }: DataPluginSetupDependencies + { bfetch, expressions, usageCollection }: DataPluginSetupDependencies ) { this.indexPatterns.setup(core); this.scriptsService.setup(core); @@ -96,6 +98,7 @@ export class DataServerPlugin core.uiSettings.register(getUiSettings()); const searchSetup = this.searchService.setup(core, { + bfetch, expressions, usageCollection, }); diff --git a/src/plugins/data/server/search/search_service.test.ts b/src/plugins/data/server/search/search_service.test.ts index 0700afd8d6c8..8a52d1d415f9 100644 --- a/src/plugins/data/server/search/search_service.test.ts +++ b/src/plugins/data/server/search/search_service.test.ts @@ -25,6 +25,8 @@ import { createFieldFormatsStartMock } from '../field_formats/mocks'; import { createIndexPatternsStartMock } from '../index_patterns/mocks'; import { SearchService, SearchServiceSetupDependencies } from './search_service'; +import { bfetchPluginMock } from '../../../bfetch/server/mocks'; +import { of } from 'rxjs'; describe('Search service', () => { let plugin: SearchService; @@ -35,15 +37,29 @@ describe('Search service', () => { const mockLogger: any = { debug: () => {}, }; - plugin = new SearchService(coreMock.createPluginInitializerContext({}), mockLogger); + const context = coreMock.createPluginInitializerContext({}); + context.config.create = jest.fn().mockImplementation(() => { + return of({ + search: { + aggs: { + shardDelay: { + enabled: true, + }, + }, + }, + }); + }); + plugin = new SearchService(context, mockLogger); mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); }); describe('setup()', () => { it('exposes proper contract', async () => { + const bfetch = bfetchPluginMock.createSetupContract(); const setup = plugin.setup(mockCoreSetup, ({ packageInfo: { version: '8' }, + bfetch, expressions: { registerFunction: jest.fn(), registerType: jest.fn(), diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index b44980164d09..a9539a8fd3c1 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -29,7 +29,8 @@ import { SharedGlobalConfig, StartServicesAccessor, } from 'src/core/server'; -import { first, switchMap } from 'rxjs/operators'; +import { catchError, first, map, switchMap } from 'rxjs/operators'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { ISearchSetup, @@ -43,7 +44,7 @@ import { AggsService } from './aggs'; import { FieldFormatsStart } from '../field_formats'; import { IndexPatternsServiceStart } from '../index_patterns'; -import { getCallMsearch, registerMsearchRoute, registerSearchRoute } from './routes'; +import { getCallMsearch, registerMsearchRoute, registerSearchRoute, shimHitsTotal } from './routes'; import { ES_SEARCH_STRATEGY, esSearchStrategyProvider } from './es_search'; import { DataPluginStart } from '../plugin'; import { UsageCollectionSetup } from '../../../usage_collection/server'; @@ -85,6 +86,7 @@ type StrategyMap = Record>; /** @internal */ export interface SearchServiceSetupDependencies { + bfetch: BfetchServerSetup; expressions: ExpressionsServerSetup; usageCollection?: UsageCollectionSetup; } @@ -106,6 +108,7 @@ export class SearchService implements Plugin { private readonly searchSourceService = new SearchSourceService(); private defaultSearchStrategyName: string = ES_SEARCH_STRATEGY; private searchStrategies: StrategyMap = {}; + private coreStart?: CoreStart; private sessionService: BackgroundSessionService = new BackgroundSessionService(); constructor( @@ -115,7 +118,7 @@ export class SearchService implements Plugin { public setup( core: CoreSetup<{}, DataPluginStart>, - { expressions, usageCollection }: SearchServiceSetupDependencies + { bfetch, expressions, usageCollection }: SearchServiceSetupDependencies ): ISearchSetup { const usage = usageCollection ? usageProvider(core) : undefined; @@ -128,10 +131,13 @@ export class SearchService implements Plugin { registerMsearchRoute(router, routeDependencies); registerSessionRoutes(router); + core.getStartServices().then(([coreStart]) => { + this.coreStart = coreStart; + }); + core.http.registerRouteHandlerContext('search', async (context, request) => { - const [coreStart] = await core.getStartServices(); - const search = this.asScopedProvider(coreStart)(request); - const session = this.sessionService.asScopedProvider(coreStart)(request); + const search = this.asScopedProvider(this.coreStart!)(request); + const session = this.sessionService.asScopedProvider(this.coreStart!)(request); return { ...search, session }; }); @@ -146,6 +152,44 @@ export class SearchService implements Plugin { ) ); + bfetch.addBatchProcessingRoute< + { request: IKibanaSearchResponse; options?: ISearchOptions }, + any + >('/internal/bsearch', (request) => { + const search = this.asScopedProvider(this.coreStart!)(request); + + return { + onBatchItem: async ({ request: requestData, options }) => { + return search + .search(requestData, options) + .pipe( + first(), + map((response) => { + return { + ...response, + ...{ + rawResponse: shimHitsTotal(response.rawResponse), + }, + }; + }), + catchError((err) => { + // eslint-disable-next-line no-throw-literal + throw { + statusCode: err.statusCode || 500, + body: { + message: err.message, + attributes: { + error: err.body?.error || err.message, + }, + }, + }; + }) + ) + .toPromise(); + }, + }; + }); + core.savedObjects.registerType(searchTelemetry); if (usageCollection) { registerUsageCollector(usageCollection, this.initializerContext); diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 73e2a68cb966..86ec784834ac 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -9,6 +9,7 @@ import { Adapters } from 'src/plugins/inspector/common'; import { ApiResponse } from '@elastic/elasticsearch'; import { Assign } from '@kbn/utility-types'; import { BehaviorSubject } from 'rxjs'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ConfigDeprecationProvider } from '@kbn/config'; import { CoreSetup } from 'src/core/server'; import { CoreSetup as CoreSetup_2 } from 'kibana/server'; @@ -948,7 +949,7 @@ export function parseInterval(interval: string): moment.Duration | null; export class Plugin implements Plugin_2 { constructor(initializerContext: PluginInitializerContext_2); // (undocumented) - setup(core: CoreSetup, { expressions, usageCollection }: DataPluginSetupDependencies): { + setup(core: CoreSetup, { bfetch, expressions, usageCollection }: DataPluginSetupDependencies): { __enhance: (enhancements: DataEnhancements) => void; search: ISearchSetup; fieldFormats: { @@ -1250,7 +1251,7 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // src/plugins/data/server/index.ts:274:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:275:1 - (ae-forgotten-export) The symbol "calcAutoIntervalLessThan" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index_patterns/index_patterns_service.ts:58:14 - (ae-forgotten-export) The symbol "IndexPatternsService" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/plugin.ts:88:66 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/plugin.ts:90:74 - (ae-forgotten-export) The symbol "DataEnhancements" needs to be exported by the entry point index.d.ts // src/plugins/data/server/search/types.ts:104:5 - (ae-forgotten-export) The symbol "ISearchStartSearchSource" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/server/ui_settings.ts b/src/plugins/data/server/ui_settings.ts index 9393700a0e77..f5360f626ac6 100644 --- a/src/plugins/data/server/ui_settings.ts +++ b/src/plugins/data/server/ui_settings.ts @@ -267,14 +267,13 @@ export function getUiSettings(): Record> { }, [UI_SETTINGS.COURIER_BATCH_SEARCHES]: { name: i18n.translate('data.advancedSettings.courier.batchSearchesTitle', { - defaultMessage: 'Batch concurrent searches', + defaultMessage: 'Use legacy search', }), value: false, type: 'boolean', description: i18n.translate('data.advancedSettings.courier.batchSearchesText', { - defaultMessage: `When disabled, dashboard panels will load individually, and search requests will terminate when users navigate - away or update the query. When enabled, dashboard panels will load together when all of the data is loaded, and - searches will not terminate.`, + defaultMessage: `Kibana uses a new search and batching infrastructure. + Enable this option if you prefer to fallback to the legacy synchronous behavior`, }), deprecation: { message: i18n.translate('data.advancedSettings.courier.batchSearchesTextDeprecation', { diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index f3f3682404e3..023cb3d19b63 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -12,6 +12,7 @@ import { ApiResponse as ApiResponse_2 } from '@elastic/elasticsearch'; import { ApplicationStart as ApplicationStart_2 } from 'kibana/public'; import { Assign } from '@kbn/utility-types'; import { BehaviorSubject } from 'rxjs'; +import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; import Boom from '@hapi/boom'; import { CoreSetup as CoreSetup_2 } from 'src/core/public'; import { CoreSetup as CoreSetup_3 } from 'kibana/public'; diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index bc7c8410d3df..eea0101ec4ed 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -6,6 +6,7 @@ "xpack", "data_enhanced" ], "requiredPlugins": [ + "bfetch", "data", "features" ], diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 948858a5ed4c..fa3206446f9f 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -7,6 +7,7 @@ import React from 'react'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { setAutocompleteService } from './services'; import { setupKqlQuerySuggestionProvider, KUERY_LANGUAGE_NAME } from './autocomplete'; @@ -16,6 +17,7 @@ import { createConnectedBackgroundSessionIndicator } from './search'; import { ConfigSchema } from '../config'; export interface DataEnhancedSetupDependencies { + bfetch: BfetchPublicSetup; data: DataPublicPluginSetup; } export interface DataEnhancedStartDependencies { @@ -33,7 +35,7 @@ export class DataEnhancedPlugin public setup( core: CoreSetup, - { data }: DataEnhancedSetupDependencies + { bfetch, data }: DataEnhancedSetupDependencies ) { data.autocomplete.addQuerySuggestionProvider( KUERY_LANGUAGE_NAME, @@ -41,6 +43,7 @@ export class DataEnhancedPlugin ); this.enhancedSearchInterceptor = new EnhancedSearchInterceptor({ + bfetch, toasts: core.notifications.toasts, http: core.http, uiSettings: core.uiSettings, diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 3f1cfc7a010c..f4d7422d1c7e 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -11,6 +11,7 @@ import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; import { SearchTimeoutError } from 'src/plugins/data/public'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; const timeTravel = (msToRun = 0) => { jest.advanceTimersByTime(msToRun); @@ -24,12 +25,13 @@ const complete = jest.fn(); let searchInterceptor: EnhancedSearchInterceptor; let mockCoreSetup: MockedKeys; let mockCoreStart: MockedKeys; +let fetchMock: jest.Mock; jest.useFakeTimers(); function mockFetchImplementation(responses: any[]) { let i = 0; - mockCoreSetup.http.fetch.mockImplementation(() => { + fetchMock.mockImplementation(() => { const { time = 0, value = {}, isError = false } = responses[i++]; return new Promise((resolve, reject) => setTimeout(() => { @@ -46,6 +48,7 @@ describe('EnhancedSearchInterceptor', () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); const dataPluginMockStart = dataPluginMock.createStartContract(); + fetchMock = jest.fn(); mockCoreSetup.uiSettings.get.mockImplementation((name: string) => { switch (name) { @@ -74,7 +77,11 @@ describe('EnhancedSearchInterceptor', () => { ]); }); + const bfetchMock = bfetchPluginMock.createSetupContract(); + bfetchMock.batchedFunction.mockReturnValue(fetchMock); + searchInterceptor = new EnhancedSearchInterceptor({ + bfetch: bfetchMock, toasts: mockCoreSetup.notifications.toasts, startServices: mockPromise as any, http: mockCoreSetup.http, @@ -245,7 +252,7 @@ describe('EnhancedSearchInterceptor', () => { expect(error).toHaveBeenCalled(); expect(error.mock.calls[0][0]).toBeInstanceOf(AbortError); - expect(mockCoreSetup.http.fetch).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(mockCoreSetup.http.delete).toHaveBeenCalled(); }); @@ -269,7 +276,7 @@ describe('EnhancedSearchInterceptor', () => { expect(error).toHaveBeenCalled(); expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); - expect(mockCoreSetup.http.fetch).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); }); @@ -301,7 +308,7 @@ describe('EnhancedSearchInterceptor', () => { expect(next).toHaveBeenCalled(); expect(error).not.toHaveBeenCalled(); - expect(mockCoreSetup.http.fetch).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); // Long enough to reach the timeout but not long enough to reach the next response @@ -309,7 +316,7 @@ describe('EnhancedSearchInterceptor', () => { expect(error).toHaveBeenCalled(); expect(error.mock.calls[0][0]).toBeInstanceOf(SearchTimeoutError); - expect(mockCoreSetup.http.fetch).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(mockCoreSetup.http.delete).toHaveBeenCalled(); }); @@ -343,7 +350,7 @@ describe('EnhancedSearchInterceptor', () => { expect(next).toHaveBeenCalled(); expect(error).not.toHaveBeenCalled(); - expect(mockCoreSetup.http.fetch).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalled(); expect(mockCoreSetup.http.delete).not.toHaveBeenCalled(); // Long enough to reach the timeout but not long enough to reach the next response @@ -351,7 +358,7 @@ describe('EnhancedSearchInterceptor', () => { expect(error).toHaveBeenCalled(); expect(error.mock.calls[0][0]).toBe(responses[1].value); - expect(mockCoreSetup.http.fetch).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(2); expect(mockCoreSetup.http.delete).toHaveBeenCalled(); }); }); @@ -383,9 +390,7 @@ describe('EnhancedSearchInterceptor', () => { await timeTravel(); - const areAllRequestsAborted = mockCoreSetup.http.fetch.mock.calls.every( - ([{ signal }]) => signal?.aborted - ); + const areAllRequestsAborted = fetchMock.mock.calls.every(([_, signal]) => signal?.aborted); expect(areAllRequestsAborted).toBe(true); expect(mockUsageCollector.trackQueriesCancelled).toBeCalledTimes(1); }); diff --git a/x-pack/plugins/security_solution/cypress/cypress.json b/x-pack/plugins/security_solution/cypress/cypress.json index 173514565c8b..364db54b4b5d 100644 --- a/x-pack/plugins/security_solution/cypress/cypress.json +++ b/x-pack/plugins/security_solution/cypress/cypress.json @@ -1,6 +1,7 @@ { "baseUrl": "http://localhost:5601", "defaultCommandTimeout": 120000, + "experimentalNetworkStubbing": true, "retries": { "runMode": 2 }, diff --git a/x-pack/plugins/security_solution/cypress/fixtures/overview_search_strategy.json b/x-pack/plugins/security_solution/cypress/fixtures/overview_search_strategy.json index d0c751701509..7a6d9d8ae294 100644 --- a/x-pack/plugins/security_solution/cypress/fixtures/overview_search_strategy.json +++ b/x-pack/plugins/security_solution/cypress/fixtures/overview_search_strategy.json @@ -8,8 +8,7 @@ "filebeatZeek": 71129, "packetbeatDNS": 1090, "packetbeatFlow": 722153, - "packetbeatTLS": 340, - "__typename": "OverviewNetworkData" + "packetbeatTLS": 340 }, "overviewHost": { "auditbeatAuditd": 123, @@ -27,7 +26,6 @@ "endgameSecurity": 397, "filebeatSystemModule": 890, "winlogbeatSecurity": 70, - "winlogbeatMWSysmonOperational": 30, - "__typename": "OverviewHostData" + "winlogbeatMWSysmonOperational": 30 } } diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts index db841d2a732c..8e3b30cddd12 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts @@ -25,7 +25,7 @@ import { markInProgressFirstAlert, goToInProgressAlerts, } from '../tasks/alerts'; -import { esArchiverLoad } from '../tasks/es_archiver'; +import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPage } from '../tasks/login'; import { DETECTIONS_URL } from '../urls/navigation'; @@ -37,6 +37,10 @@ describe('Alerts', () => { loginAndWaitForPage(DETECTIONS_URL); }); + afterEach(() => { + esArchiverUnload('alerts'); + }); + it('Closes and opens alerts', () => { waitForAlertsPanelToBeLoaded(); waitForAlertsToBeLoaded(); @@ -165,6 +169,10 @@ describe('Alerts', () => { loginAndWaitForPage(DETECTIONS_URL); }); + afterEach(() => { + esArchiverUnload('closed_alerts'); + }); + it('Open one alert when more than one closed alerts are selected', () => { waitForAlerts(); goToClosedAlerts(); @@ -212,6 +220,10 @@ describe('Alerts', () => { loginAndWaitForPage(DETECTIONS_URL); }); + afterEach(() => { + esArchiverUnload('alerts'); + }); + it('Mark one alert in progress when more than one open alerts are selected', () => { waitForAlerts(); waitForAlertsToBeLoaded(); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts index 6a62caecfaa6..2d21e3d333c0 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts @@ -83,7 +83,8 @@ describe('Alerts detection rules', () => { }); }); - it('Auto refreshes rules', () => { + // FIXME: UI hangs on loading + it.skip('Auto refreshes rules', () => { cy.clock(Date.now()); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index dfe984cba381..5fee3c0bce13 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -225,7 +225,7 @@ describe('Custom detection rules deletion and edition', () => { goToManageAlertsDetectionRules(); }); - after(() => { + afterEach(() => { esArchiverUnload('custom_rules'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts index c2be6b2883c8..eb8448233c62 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_export.spec.ts @@ -17,8 +17,7 @@ import { DETECTIONS_URL } from '../urls/navigation'; const EXPECTED_EXPORTED_RULE_FILE_PATH = 'cypress/test_files/expected_rules_export.ndjson'; -// FLAKY: https://github.com/elastic/kibana/issues/69849 -describe.skip('Export rules', () => { +describe('Export rules', () => { before(() => { esArchiverLoad('export_rule'); cy.server(); diff --git a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts index 69094cad7456..0d12019adbc9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview.spec.ts @@ -11,9 +11,12 @@ import { loginAndWaitForPage } from '../tasks/login'; import { OVERVIEW_URL } from '../urls/navigation'; +import overviewFixture from '../fixtures/overview_search_strategy.json'; +import emptyInstance from '../fixtures/empty_instance.json'; + describe('Overview Page', () => { it('Host stats render with correct values', () => { - cy.stubSearchStrategyApi('overview_search_strategy'); + cy.stubSearchStrategyApi(overviewFixture, 'overviewHost'); loginAndWaitForPage(OVERVIEW_URL); expandHostStats(); @@ -23,7 +26,7 @@ describe('Overview Page', () => { }); it('Network stats render with correct values', () => { - cy.stubSearchStrategyApi('overview_search_strategy'); + cy.stubSearchStrategyApi(overviewFixture, 'overviewNetwork'); loginAndWaitForPage(OVERVIEW_URL); expandNetworkStats(); @@ -33,14 +36,9 @@ describe('Overview Page', () => { }); describe('with no data', () => { - before(() => { - cy.server(); - cy.fixture('empty_instance').as('emptyInstance'); - loginAndWaitForPage(OVERVIEW_URL); - cy.route('POST', '**/internal/search/securitySolutionIndexFields', '@emptyInstance'); - }); - it('Splash screen should be here', () => { + cy.stubSearchStrategyApi(emptyInstance, undefined, 'securitySolutionIndexFields'); + loginAndWaitForPage(OVERVIEW_URL); cy.get(OVERVIEW_EMPTY_PAGE).should('be.visible'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index c249e0a77690..95a52794628b 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -30,24 +30,66 @@ // -- This is will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) -Cypress.Commands.add('stubSecurityApi', function (dataFileName) { - cy.on('window:before:load', (win) => { - win.fetch = null; - }); - cy.server(); - cy.fixture(dataFileName).as(`${dataFileName}JSON`); - cy.route('POST', 'api/solutions/security/graphql', `@${dataFileName}JSON`); -}); +import { findIndex } from 'lodash/fp'; + +const getFindRequestConfig = (searchStrategyName, factoryQueryType) => { + if (!factoryQueryType) { + return { + options: { strategy: searchStrategyName }, + }; + } + + return { + options: { strategy: searchStrategyName }, + request: { factoryQueryType }, + }; +}; Cypress.Commands.add( 'stubSearchStrategyApi', - function (dataFileName, searchStrategyName = 'securitySolutionSearchStrategy') { - cy.on('window:before:load', (win) => { - win.fetch = null; + function (stubObject, factoryQueryType, searchStrategyName = 'securitySolutionSearchStrategy') { + cy.route2('POST', '/internal/bsearch', (req) => { + const bodyObj = JSON.parse(req.body); + const findRequestConfig = getFindRequestConfig(searchStrategyName, factoryQueryType); + + const requestIndex = findIndex(findRequestConfig, bodyObj.batch); + + if (requestIndex > -1) { + return req.reply((res) => { + const responseObjectsArray = res.body.split('\n').map((responseString) => { + try { + return JSON.parse(responseString); + } catch { + return responseString; + } + }); + const responseIndex = findIndex({ id: requestIndex }, responseObjectsArray); + + const stubbedResponseObjectsArray = [...responseObjectsArray]; + stubbedResponseObjectsArray[responseIndex] = { + ...stubbedResponseObjectsArray[responseIndex], + result: { + ...stubbedResponseObjectsArray[responseIndex].result, + ...stubObject, + }, + }; + + const stubbedResponse = stubbedResponseObjectsArray + .map((object) => { + try { + return JSON.stringify(object); + } catch { + return object; + } + }) + .join('\n'); + + res.send(stubbedResponse); + }); + } + + req.reply(); }); - cy.server(); - cy.fixture(dataFileName).as(`${dataFileName}JSON`); - cy.route('POST', `internal/search/${searchStrategyName}`, `@${dataFileName}JSON`); } ); diff --git a/x-pack/plugins/security_solution/cypress/support/index.d.ts b/x-pack/plugins/security_solution/cypress/support/index.d.ts index fb55a2890c8b..06285abba653 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.d.ts +++ b/x-pack/plugins/security_solution/cypress/support/index.d.ts @@ -7,8 +7,11 @@ declare namespace Cypress { interface Chainable { promisify(): Promise; - stubSecurityApi(dataFileName: string): Chainable; - stubSearchStrategyApi(dataFileName: string, searchStrategyName?: string): Chainable; + stubSearchStrategyApi( + stubObject: Record, + factoryQueryType?: string, + searchStrategyName?: string + ): Chainable; attachFile(fileName: string, fileType?: string): Chainable; waitUntil( fn: (subject: Subject) => boolean | Chainable, diff --git a/x-pack/test/security_solution_cypress/es_archives/timeline_alerts/mappings.json b/x-pack/test/security_solution_cypress/es_archives/timeline_alerts/mappings.json index a1a9e7bfeae7..abdec252471b 100644 --- a/x-pack/test/security_solution_cypress/es_archives/timeline_alerts/mappings.json +++ b/x-pack/test/security_solution_cypress/es_archives/timeline_alerts/mappings.json @@ -157,6 +157,9 @@ "throttle": { "type": "keyword" }, + "updatedAt": { + "type": "date" + }, "updatedBy": { "type": "keyword" } @@ -9060,4 +9063,4 @@ } } } -} \ No newline at end of file +} From 58297fa131255158975449915b469ef45bbba86b Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Wed, 25 Nov 2020 06:36:24 -0800 Subject: [PATCH 64/89] Deprecate `xpack.task_manager.index` setting (#84155) * Deprecate `xpack.task_manager.index` setting * Updating developer docs about configuring task manager settings --- x-pack/plugins/task_manager/README.md | 2 +- .../plugins/task_manager/server/index.test.ts | 43 +++++++++++++++++++ x-pack/plugins/task_manager/server/index.ts | 18 ++++++-- 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/task_manager/server/index.test.ts diff --git a/x-pack/plugins/task_manager/README.md b/x-pack/plugins/task_manager/README.md index 744d657bcd79..d3c8ecb6c450 100644 --- a/x-pack/plugins/task_manager/README.md +++ b/x-pack/plugins/task_manager/README.md @@ -43,7 +43,7 @@ The task_manager can be configured via `taskManager` config options (e.g. `taskM - `max_attempts` - The maximum number of times a task will be attempted before being abandoned as failed - `poll_interval` - How often the background worker should check the task_manager index for more work - `max_poll_inactivity_cycles` - How many poll intervals is work allowed to block polling for before it's timed out. This does not include task execution, as task execution does not block the polling, but rather includes work needed to manage Task Manager's state. -- `index` - The name of the index that the task_manager +- `index` - **deprecated** The name of the index that the task_manager will use. This is deprecated, and will be removed starting in 8.0 - `max_workers` - The maximum number of tasks a Kibana will run concurrently (defaults to 10) - `credentials` - Encrypted user credentials. All tasks will run in the security context of this user. See [this issue](https://github.com/elastic/dev/issues/1045) for a discussion on task scheduler security. - `override_num_workers`: An object of `taskType: number` that overrides the `num_workers` for tasks diff --git a/x-pack/plugins/task_manager/server/index.test.ts b/x-pack/plugins/task_manager/server/index.test.ts new file mode 100644 index 000000000000..3f25f4403d35 --- /dev/null +++ b/x-pack/plugins/task_manager/server/index.test.ts @@ -0,0 +1,43 @@ +/* + * 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 { config } from './index'; +import { applyDeprecations, configDeprecationFactory } from '@kbn/config'; + +const CONFIG_PATH = 'xpack.task_manager'; + +const applyTaskManagerDeprecations = (settings: Record = {}) => { + const deprecations = config.deprecations!(configDeprecationFactory); + const deprecationMessages: string[] = []; + const _config = { + [CONFIG_PATH]: settings, + }; + const migrated = applyDeprecations( + _config, + deprecations.map((deprecation) => ({ + deprecation, + path: CONFIG_PATH, + })), + (msg) => deprecationMessages.push(msg) + ); + return { + messages: deprecationMessages, + migrated, + }; +}; + +describe('deprecations', () => { + ['.foo', '.kibana_task_manager'].forEach((index) => { + it('logs a warning if index is set', () => { + const { messages } = applyTaskManagerDeprecations({ index }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.task_manager.index\\" is deprecated. Multitenancy by changing \\"kibana.index\\" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details", + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/task_manager/server/index.ts b/x-pack/plugins/task_manager/server/index.ts index cf70e68437cc..8f96e10430b3 100644 --- a/x-pack/plugins/task_manager/server/index.ts +++ b/x-pack/plugins/task_manager/server/index.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from 'src/core/server'; +import { get } from 'lodash'; +import { PluginConfigDescriptor, PluginInitializerContext } from 'src/core/server'; import { TaskManagerPlugin } from './plugin'; -import { configSchema } from './config'; +import { configSchema, TaskManagerConfig } from './config'; export const plugin = (initContext: PluginInitializerContext) => new TaskManagerPlugin(initContext); @@ -26,6 +27,17 @@ export { TaskManagerStartContract, } from './plugin'; -export const config = { +export const config: PluginConfigDescriptor = { schema: configSchema, + deprecations: () => [ + (settings, fromPath, log) => { + const taskManager = get(settings, fromPath); + if (taskManager?.index) { + log( + `"${fromPath}.index" is deprecated. Multitenancy by changing "kibana.index" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details` + ); + } + return settings; + }, + ], }; From 0fe8a65a23c3de3381b58219a1e4f169b1354133 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 25 Nov 2020 09:00:10 -0600 Subject: [PATCH 65/89] [Workplace Search] Migrate DisplaySettings tree (#84283) * Initial copy/paste of components Changes for pre-commit hooks were: - Linting - Lodash imports - Fixed warnings for `jsx-a11y/mouse-events-have-key-events` with stubbed onFocus and onBlue events with FIXME comments * Add server routes * Remove reference to shared lib This one-liner appears only once in ent-search so adding it here in the logic file` * Fix paths * Add types and fix TypeScript issues * Replace FlashMessages with global component * More explicit Result type * Remove routes/http in favor of HttpLogic * Fix server routes `urlFieldIsLinkable` was missing and `detailFields` can either be an object or an array of objects * Add base styles These were ported from ent-search. Decided to use spacers where some global styles were missing. * Kibana prefers underscores in URLs Was only going to do display-settings and result-details but decided to YOLO all of them --- .../applications/workplace_search/routes.ts | 36 +- .../applications/workplace_search/types.ts | 23 ++ .../display_settings/custom_source_icon.tsx | 36 ++ .../display_settings/display_settings.scss | 206 +++++++++++ .../display_settings/display_settings.tsx | 141 +++++++ .../display_settings_logic.ts | 350 ++++++++++++++++++ .../display_settings_router.tsx | 31 +- .../example_result_detail_card.tsx | 75 ++++ .../example_search_result_group.tsx | 74 ++++ .../example_standout_result.tsx | 66 ++++ .../display_settings/field_editor_modal.tsx | 101 +++++ .../display_settings/result_detail.tsx | 146 ++++++++ .../display_settings/search_results.tsx | 164 ++++++++ .../display_settings/subtitle_field.tsx | 35 ++ .../display_settings/title_field.tsx | 35 ++ .../routes/workplace_search/sources.test.ts | 152 ++++++++ .../server/routes/workplace_search/sources.ts | 95 +++++ 17 files changed, 1747 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/custom_source_icon.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 3ec22ede888a..14c288de5a0c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -46,7 +46,7 @@ export const ENT_SEARCH_LICENSE_MANAGEMENT = `${ENT_SEARCH_DOCS_PREFIX}/license- export const PERSONAL_PATH = '/p'; -export const ROLE_MAPPINGS_PATH = '/role-mappings'; +export const ROLE_MAPPINGS_PATH = '/role_mappings'; export const ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/:roleId`; export const ROLE_MAPPING_NEW_PATH = `${ROLE_MAPPINGS_PATH}/new`; @@ -63,18 +63,18 @@ export const PERSONAL_SOURCES_PATH = `${PERSONAL_PATH}${SOURCES_PATH}`; export const SOURCE_ADDED_PATH = `${SOURCES_PATH}/added`; export const ADD_SOURCE_PATH = `${SOURCES_PATH}/add`; export const ADD_BOX_PATH = `${SOURCES_PATH}/add/box`; -export const ADD_CONFLUENCE_PATH = `${SOURCES_PATH}/add/confluence-cloud`; -export const ADD_CONFLUENCE_SERVER_PATH = `${SOURCES_PATH}/add/confluence-server`; +export const ADD_CONFLUENCE_PATH = `${SOURCES_PATH}/add/confluence_cloud`; +export const ADD_CONFLUENCE_SERVER_PATH = `${SOURCES_PATH}/add/confluence_server`; export const ADD_DROPBOX_PATH = `${SOURCES_PATH}/add/dropbox`; -export const ADD_GITHUB_ENTERPRISE_PATH = `${SOURCES_PATH}/add/github-enterprise-server`; +export const ADD_GITHUB_ENTERPRISE_PATH = `${SOURCES_PATH}/add/github_enterprise_server`; export const ADD_GITHUB_PATH = `${SOURCES_PATH}/add/github`; export const ADD_GMAIL_PATH = `${SOURCES_PATH}/add/gmail`; -export const ADD_GOOGLE_DRIVE_PATH = `${SOURCES_PATH}/add/google-drive`; -export const ADD_JIRA_PATH = `${SOURCES_PATH}/add/jira-cloud`; -export const ADD_JIRA_SERVER_PATH = `${SOURCES_PATH}/add/jira-server`; +export const ADD_GOOGLE_DRIVE_PATH = `${SOURCES_PATH}/add/google_drive`; +export const ADD_JIRA_PATH = `${SOURCES_PATH}/add/jira_cloud`; +export const ADD_JIRA_SERVER_PATH = `${SOURCES_PATH}/add/jira_server`; export const ADD_ONEDRIVE_PATH = `${SOURCES_PATH}/add/onedrive`; export const ADD_SALESFORCE_PATH = `${SOURCES_PATH}/add/salesforce`; -export const ADD_SALESFORCE_SANDBOX_PATH = `${SOURCES_PATH}/add/salesforce-sandbox`; +export const ADD_SALESFORCE_SANDBOX_PATH = `${SOURCES_PATH}/add/salesforce_sandbox`; export const ADD_SERVICENOW_PATH = `${SOURCES_PATH}/add/servicenow`; export const ADD_SHAREPOINT_PATH = `${SOURCES_PATH}/add/sharepoint`; export const ADD_SLACK_PATH = `${SOURCES_PATH}/add/slack`; @@ -86,30 +86,30 @@ export const PERSONAL_SETTINGS_PATH = `${PERSONAL_PATH}/settings`; export const SOURCE_DETAILS_PATH = `${SOURCES_PATH}/:sourceId`; export const SOURCE_CONTENT_PATH = `${SOURCES_PATH}/:sourceId/content`; export const SOURCE_SCHEMAS_PATH = `${SOURCES_PATH}/:sourceId/schemas`; -export const SOURCE_DISPLAY_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/display-settings`; +export const SOURCE_DISPLAY_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/display_settings`; export const SOURCE_SETTINGS_PATH = `${SOURCES_PATH}/:sourceId/settings`; -export const REINDEX_JOB_PATH = `${SOURCES_PATH}/:sourceId/schema-errors/:activeReindexJobId`; +export const REINDEX_JOB_PATH = `${SOURCES_PATH}/:sourceId/schema_errors/:activeReindexJobId`; export const DISPLAY_SETTINGS_SEARCH_RESULT_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/`; -export const DISPLAY_SETTINGS_RESULT_DETAIL_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/result-detail`; +export const DISPLAY_SETTINGS_RESULT_DETAIL_PATH = `${SOURCE_DISPLAY_SETTINGS_PATH}/result_detail`; export const ORG_SETTINGS_PATH = '/settings'; export const ORG_SETTINGS_CUSTOMIZE_PATH = `${ORG_SETTINGS_PATH}/customize`; export const ORG_SETTINGS_CONNECTORS_PATH = `${ORG_SETTINGS_PATH}/connectors`; export const ORG_SETTINGS_OAUTH_APPLICATION_PATH = `${ORG_SETTINGS_PATH}/oauth`; export const EDIT_BOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/box/edit`; -export const EDIT_CONFLUENCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence-cloud/edit`; -export const EDIT_CONFLUENCE_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence-server/edit`; +export const EDIT_CONFLUENCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_cloud/edit`; +export const EDIT_CONFLUENCE_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_server/edit`; export const EDIT_DROPBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/dropbox/edit`; -export const EDIT_GITHUB_ENTERPRISE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github-enterprise-server/edit`; +export const EDIT_GITHUB_ENTERPRISE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github_enterprise_server/edit`; export const EDIT_GITHUB_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github/edit`; export const EDIT_GMAIL_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/gmail/edit`; -export const EDIT_GOOGLE_DRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/google-drive/edit`; -export const EDIT_JIRA_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira-cloud/edit`; -export const EDIT_JIRA_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira-server/edit`; +export const EDIT_GOOGLE_DRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/google_drive/edit`; +export const EDIT_JIRA_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_cloud/edit`; +export const EDIT_JIRA_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_server/edit`; export const EDIT_ONEDRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/onedrive/edit`; export const EDIT_SALESFORCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce/edit`; -export const EDIT_SALESFORCE_SANDBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce-sandbox/edit`; +export const EDIT_SALESFORCE_SANDBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce_sandbox/edit`; export const EDIT_SERVICENOW_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/servicenow/edit`; export const EDIT_SHAREPOINT_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/sharepoint/edit`; export const EDIT_SLACK_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/slack/edit`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 73e7f7ed701d..9bda686ebbf0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -181,3 +181,26 @@ export interface CustomSource { name: string; id: string; } + +export interface Result { + [key: string]: string; +} + +export interface OptionValue { + value: string; + text: string; +} + +export interface DetailField { + fieldName: string; + label: string; +} + +export interface SearchResultConfig { + titleField: string | null; + subtitleField: string | null; + descriptionField: string | null; + urlField: string | null; + color: string; + detailFields: DetailField[]; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/custom_source_icon.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/custom_source_icon.tsx new file mode 100644 index 000000000000..16129324b56d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/custom_source_icon.tsx @@ -0,0 +1,36 @@ +/* + * 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'; + +const BLACK_RGB = '#000'; + +interface CustomSourceIconProps { + color?: string; +} + +export const CustomSourceIcon: React.FC = ({ color = BLACK_RGB }) => ( + + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.scss new file mode 100644 index 000000000000..27935104f4ef --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.scss @@ -0,0 +1,206 @@ +/* + * 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. + */ + +// -------------------------------------------------- +// Custom Source display settings +// -------------------------------------------------- + +@mixin source_name { + font-size: .6875em; + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.06em; +} + +@mixin example_result_box_shadow { + box-shadow: + 0 1px 3px rgba(black, 0.1), + 0 0 20px $euiColorLightestShade; +} + +// Wrapper +.custom-source-display-settings { + font-size: 16px; +} + +// Example result content +.example-result-content { + & > * { + line-height: 1.5em; + } + + &__title { + font-size: 1em; + font-weight: 600; + color: $euiColorPrimary; + + .example-result-detail-card & { + font-size: 20px; + } + } + + &__subtitle, + &__description { + font-size: .875; + } + + &__subtitle { + color: $euiColorDarkestShade; + } + + &__description { + padding: .1rem 0 .125rem .35rem; + border-left: 3px solid $euiColorLightShade; + color: $euiColorDarkShade; + line-height: 1.8; + word-break: break-word; + + @supports (display: -webkit-box) { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__url { + .example-result-detail-card & { + color: $euiColorDarkShade; + } + } +} + +.example-result-content-placeholder { + color: $euiColorMediumShade; +} + +// Example standout result +.example-standout-result { + border-radius: 4px; + overflow: hidden; + @include example_result_box_shadow; + + &__header, + &__content { + padding-left: 1em; + padding-right: 1em; + } + + &__content { + padding-top: 1em; + padding-bottom: 1em; + } + + &__source-name { + line-height: 34px; + @include source_name; + } +} + +// Example result group +.example-result-group { + &__header { + padding: 0 .5em; + border-radius: 4px; + display: inline-flex; + align-items: center; + + .euiIcon { + margin-right: .25rem; + } + } + + &__source-name { + line-height: 1.75em; + @include source_name; + } + + &__content { + display: flex; + align-items: stretch; + padding: .75em 0; + } + + &__border { + width: 4px; + border-radius: 2px; + flex-shrink: 0; + margin-left: .875rem; + } + + &__results { + flex: 1; + max-width: 100%; + } +} + +.example-grouped-result { + padding: 1em; +} + +.example-result-field-hover { + background: lighten($euiColorVis1_behindText, 30%); + position: relative; + + &:before, + &:after { + content: ''; + position: absolute; + height: 100%; + width: 4px; + background: lighten($euiColorVis1_behindText, 30%); + } + + &:before { + right: 100%; + border-radius: 2px 0 0 2px; + } + + &:after { + left: 100%; + border-radius: 0 2px 2px 0; + } + + .example-result-content-placeholder { + color: $euiColorFullShade; + } +} + +.example-result-detail-card { + @include example_result_box_shadow; + + &__header { + position: relative; + padding: 1.25em 1em 0; + } + + &__border { + height: 4px; + position: absolute; + top: 0; + right: 0; + left: 0; + } + + &__source-name { + margin-bottom: 1em; + font-weight: 500; + } + + &__field { + padding: 1em; + + & + & { + border-top: 1px solid $euiColorLightShade; + } + } +} + +.visible-fields-container { + background: $euiColorLightestShade; + border-color: transparent; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx new file mode 100644 index 000000000000..e34728beef5e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx @@ -0,0 +1,141 @@ +/* + * 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, { FormEvent, useEffect } from 'react'; + +import { History } from 'history'; +import { useActions, useValues } from 'kea'; +import { useHistory } from 'react-router-dom'; + +import './display_settings.scss'; + +import { + EuiButton, + EuiEmptyPrompt, + EuiTabbedContent, + EuiPanel, + EuiTabbedContentTab, +} from '@elastic/eui'; + +import { + DISPLAY_SETTINGS_RESULT_DETAIL_PATH, + DISPLAY_SETTINGS_SEARCH_RESULT_PATH, + getContentSourcePath, +} from '../../../../routes'; + +import { AppLogic } from '../../../../app_logic'; + +import { Loading } from '../../../../../shared/loading'; +import { ViewContentHeader } from '../../../../components/shared/view_content_header'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { FieldEditorModal } from './field_editor_modal'; +import { ResultDetail } from './result_detail'; +import { SearchResults } from './search_results'; + +const UNSAVED_MESSAGE = + 'Your display settings have not been saved. Are you sure you want to leave?'; + +interface DisplaySettingsProps { + tabId: number; +} + +export const DisplaySettings: React.FC = ({ tabId }) => { + const history = useHistory() as History; + const { initializeDisplaySettings, setServerData, resetDisplaySettingsState } = useActions( + DisplaySettingsLogic + ); + + const { + dataLoading, + sourceId, + addFieldModalVisible, + unsavedChanges, + exampleDocuments, + } = useValues(DisplaySettingsLogic); + + const { isOrganization } = useValues(AppLogic); + + const hasDocuments = exampleDocuments.length > 0; + + useEffect(() => { + initializeDisplaySettings(); + return resetDisplaySettingsState; + }, []); + + useEffect(() => { + window.onbeforeunload = hasDocuments && unsavedChanges ? () => UNSAVED_MESSAGE : null; + return () => { + window.onbeforeunload = null; + }; + }, [unsavedChanges]); + + if (dataLoading) return ; + + const tabs = [ + { + id: 'search_results', + name: 'Search Results', + content: , + }, + { + id: 'result_detail', + name: 'Result Detail', + content: , + }, + ] as EuiTabbedContentTab[]; + + const onSelectedTabChanged = (tab: EuiTabbedContentTab) => { + const path = + tab.id === tabs[1].id + ? getContentSourcePath(DISPLAY_SETTINGS_RESULT_DETAIL_PATH, sourceId, isOrganization) + : getContentSourcePath(DISPLAY_SETTINGS_SEARCH_RESULT_PATH, sourceId, isOrganization); + + history.push(path); + }; + + const handleFormSubmit = (e: FormEvent) => { + e.preventDefault(); + setServerData(); + }; + + return ( + <> +
    + + Save + + ) : null + } + /> + {hasDocuments ? ( + + ) : ( + + You have no content yet} + body={ +

    You need some content to display in order to configure the display settings.

    + } + /> +
    + )} + + {addFieldModalVisible && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts new file mode 100644 index 000000000000..c52665524f56 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.ts @@ -0,0 +1,350 @@ +/* + * 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 { cloneDeep, isEqual, differenceBy } from 'lodash'; +import { DropResult } from 'react-beautiful-dnd'; + +import { kea, MakeLogicType } from 'kea'; + +import { HttpLogic } from '../../../../../shared/http'; + +import { + setSuccessMessage, + FlashMessagesLogic, + flashAPIErrors, +} from '../../../../../shared/flash_messages'; + +import { AppLogic } from '../../../../app_logic'; +import { SourceLogic } from '../../source_logic'; + +const SUCCESS_MESSAGE = 'Display Settings have been successfuly updated.'; + +import { DetailField, SearchResultConfig, OptionValue, Result } from '../../../../types'; + +export interface DisplaySettingsResponseProps { + sourceName: string; + searchResultConfig: SearchResultConfig; + schemaFields: object; + exampleDocuments: Result[]; +} + +export interface DisplaySettingsInitialData extends DisplaySettingsResponseProps { + sourceId: string; + serverRoute: string; +} + +interface DisplaySettingsActions { + initializeDisplaySettings(): void; + setServerData(): void; + onInitializeDisplaySettings( + displaySettingsProps: DisplaySettingsInitialData + ): DisplaySettingsInitialData; + setServerResponseData( + displaySettingsProps: DisplaySettingsResponseProps + ): DisplaySettingsResponseProps; + setTitleField(titleField: string | null): string | null; + setUrlField(urlField: string): string; + setSubtitleField(subtitleField: string | null): string | null; + setDescriptionField(descriptionField: string | null): string | null; + setColorField(hex: string): string; + setDetailFields(result: DropResult): { result: DropResult }; + openEditDetailField(editFieldIndex: number | null): number | null; + removeDetailField(index: number): number; + addDetailField(newField: DetailField): DetailField; + updateDetailField( + updatedField: DetailField, + index: number | null + ): { updatedField: DetailField; index: number }; + toggleFieldEditorModal(): void; + toggleTitleFieldHover(): void; + toggleSubtitleFieldHover(): void; + toggleDescriptionFieldHover(): void; + toggleUrlFieldHover(): void; + resetDisplaySettingsState(): void; +} + +interface DisplaySettingsValues { + sourceName: string; + sourceId: string; + schemaFields: object; + exampleDocuments: Result[]; + serverSearchResultConfig: SearchResultConfig; + searchResultConfig: SearchResultConfig; + serverRoute: string; + editFieldIndex: number | null; + dataLoading: boolean; + addFieldModalVisible: boolean; + titleFieldHover: boolean; + urlFieldHover: boolean; + subtitleFieldHover: boolean; + descriptionFieldHover: boolean; + fieldOptions: OptionValue[]; + optionalFieldOptions: OptionValue[]; + availableFieldOptions: OptionValue[]; + unsavedChanges: boolean; +} + +const defaultSearchResultConfig = { + titleField: '', + subtitleField: '', + descriptionField: '', + urlField: '', + color: '#000000', + detailFields: [], +}; + +export const DisplaySettingsLogic = kea< + MakeLogicType +>({ + actions: { + onInitializeDisplaySettings: (displaySettingsProps: DisplaySettingsInitialData) => + displaySettingsProps, + setServerResponseData: (displaySettingsProps: DisplaySettingsResponseProps) => + displaySettingsProps, + setTitleField: (titleField: string) => titleField, + setUrlField: (urlField: string) => urlField, + setSubtitleField: (subtitleField: string | null) => subtitleField, + setDescriptionField: (descriptionField: string) => descriptionField, + setColorField: (hex: string) => hex, + setDetailFields: (result: DropResult) => ({ result }), + openEditDetailField: (editFieldIndex: number | null) => editFieldIndex, + removeDetailField: (index: number) => index, + addDetailField: (newField: DetailField) => newField, + updateDetailField: (updatedField: DetailField, index: number) => ({ updatedField, index }), + toggleFieldEditorModal: () => true, + toggleTitleFieldHover: () => true, + toggleSubtitleFieldHover: () => true, + toggleDescriptionFieldHover: () => true, + toggleUrlFieldHover: () => true, + resetDisplaySettingsState: () => true, + initializeDisplaySettings: () => true, + setServerData: () => true, + }, + reducers: { + sourceName: [ + '', + { + onInitializeDisplaySettings: (_, { sourceName }) => sourceName, + }, + ], + sourceId: [ + '', + { + onInitializeDisplaySettings: (_, { sourceId }) => sourceId, + }, + ], + schemaFields: [ + {}, + { + onInitializeDisplaySettings: (_, { schemaFields }) => schemaFields, + }, + ], + exampleDocuments: [ + [], + { + onInitializeDisplaySettings: (_, { exampleDocuments }) => exampleDocuments, + }, + ], + serverSearchResultConfig: [ + defaultSearchResultConfig, + { + onInitializeDisplaySettings: (_, { searchResultConfig }) => + setDefaultColor(searchResultConfig), + setServerResponseData: (_, { searchResultConfig }) => searchResultConfig, + }, + ], + searchResultConfig: [ + defaultSearchResultConfig, + { + onInitializeDisplaySettings: (_, { searchResultConfig }) => + setDefaultColor(searchResultConfig), + setServerResponseData: (_, { searchResultConfig }) => searchResultConfig, + setTitleField: (searchResultConfig, titleField) => ({ ...searchResultConfig, titleField }), + setSubtitleField: (searchResultConfig, subtitleField) => ({ + ...searchResultConfig, + subtitleField, + }), + setUrlField: (searchResultConfig, urlField) => ({ ...searchResultConfig, urlField }), + setDescriptionField: (searchResultConfig, descriptionField) => ({ + ...searchResultConfig, + descriptionField, + }), + setColorField: (searchResultConfig, color) => ({ ...searchResultConfig, color }), + setDetailFields: (searchResultConfig, { result: { destination, source } }) => { + const detailFields = cloneDeep(searchResultConfig.detailFields); + const element = detailFields[source.index]; + detailFields.splice(source.index, 1); + detailFields.splice(destination!.index, 0, element); + return { + ...searchResultConfig, + detailFields, + }; + }, + addDetailField: (searchResultConfig, newfield) => { + const detailFields = cloneDeep(searchResultConfig.detailFields); + detailFields.push(newfield); + return { + ...searchResultConfig, + detailFields, + }; + }, + removeDetailField: (searchResultConfig, index) => { + const detailFields = cloneDeep(searchResultConfig.detailFields); + detailFields.splice(index, 1); + return { + ...searchResultConfig, + detailFields, + }; + }, + updateDetailField: (searchResultConfig, { updatedField, index }) => { + const detailFields = cloneDeep(searchResultConfig.detailFields); + detailFields[index] = updatedField; + return { + ...searchResultConfig, + detailFields, + }; + }, + }, + ], + serverRoute: [ + '', + { + onInitializeDisplaySettings: (_, { serverRoute }) => serverRoute, + }, + ], + editFieldIndex: [ + null, + { + openEditDetailField: (_, openEditDetailField) => openEditDetailField, + toggleFieldEditorModal: () => null, + }, + ], + dataLoading: [ + true, + { + onInitializeDisplaySettings: () => false, + }, + ], + addFieldModalVisible: [ + false, + { + toggleFieldEditorModal: (addFieldModalVisible) => !addFieldModalVisible, + openEditDetailField: () => true, + updateDetailField: () => false, + addDetailField: () => false, + }, + ], + titleFieldHover: [ + false, + { + toggleTitleFieldHover: (titleFieldHover) => !titleFieldHover, + }, + ], + urlFieldHover: [ + false, + { + toggleUrlFieldHover: (urlFieldHover) => !urlFieldHover, + }, + ], + subtitleFieldHover: [ + false, + { + toggleSubtitleFieldHover: (subtitleFieldHover) => !subtitleFieldHover, + }, + ], + descriptionFieldHover: [ + false, + { + toggleDescriptionFieldHover: (addFieldModalVisible) => !addFieldModalVisible, + }, + ], + }, + selectors: ({ selectors }) => ({ + fieldOptions: [ + () => [selectors.schemaFields], + (schemaFields) => Object.keys(schemaFields).map(euiSelectObjectFromValue), + ], + optionalFieldOptions: [ + () => [selectors.fieldOptions], + (fieldOptions) => { + const optionalFieldOptions = cloneDeep(fieldOptions); + optionalFieldOptions.unshift({ value: '', text: '' }); + return optionalFieldOptions; + }, + ], + // We don't want to let the user add a duplicate detailField. + availableFieldOptions: [ + () => [selectors.fieldOptions, selectors.searchResultConfig], + (fieldOptions, { detailFields }) => { + const usedFields = detailFields.map((usedField: DetailField) => + euiSelectObjectFromValue(usedField.fieldName) + ); + return differenceBy(fieldOptions, usedFields, 'value'); + }, + ], + unsavedChanges: [ + () => [selectors.searchResultConfig, selectors.serverSearchResultConfig], + (uiConfig, serverConfig) => !isEqual(uiConfig, serverConfig), + ], + }), + listeners: ({ actions, values }) => ({ + initializeDisplaySettings: async () => { + const { isOrganization } = AppLogic.values; + const { + contentSource: { id: sourceId }, + } = SourceLogic.values; + + const route = isOrganization + ? `/api/workplace_search/org/sources/${sourceId}/display_settings/config` + : `/api/workplace_search/account/sources/${sourceId}/display_settings/config`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.onInitializeDisplaySettings({ + isOrganization, + sourceId, + serverRoute: route, + ...response, + }); + } catch (e) { + flashAPIErrors(e); + } + }, + setServerData: async () => { + const { searchResultConfig, serverRoute } = values; + + try { + const response = await HttpLogic.values.http.post(serverRoute, { + body: JSON.stringify({ ...searchResultConfig }), + }); + actions.setServerResponseData(response); + } catch (e) { + flashAPIErrors(e); + } + }, + setServerResponseData: () => { + setSuccessMessage(SUCCESS_MESSAGE); + }, + toggleFieldEditorModal: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + resetDisplaySettingsState: () => { + FlashMessagesLogic.actions.clearFlashMessages(); + }, + }), +}); + +const euiSelectObjectFromValue = (value: string) => ({ text: value, value }); + +// By default, the color is `null` on the server. The color is a required field and the +// EuiColorPicker components doesn't allow the field to be required so the form can be +// submitted with no color and this results in a server error. The default should be black +// and this allows the `searchResultConfig` and the `serverSearchResultConfig` reducers to +// stay synced on initialization. +const setDefaultColor = (searchResultConfig: SearchResultConfig) => ({ + ...searchResultConfig, + color: searchResultConfig.color || '#000000', +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_router.tsx index 5cebaad95e3a..01ac93735b8a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_router.tsx @@ -6,4 +6,33 @@ import React from 'react'; -export const DisplaySettingsRouter: React.FC = () => <>Display Settings Placeholder; +import { useValues } from 'kea'; +import { Route, Switch } from 'react-router-dom'; + +import { AppLogic } from '../../../../app_logic'; + +import { + DISPLAY_SETTINGS_RESULT_DETAIL_PATH, + DISPLAY_SETTINGS_SEARCH_RESULT_PATH, + getSourcesPath, +} from '../../../../routes'; + +import { DisplaySettings } from './display_settings'; + +export const DisplaySettingsRouter: React.FC = () => { + const { isOrganization } = useValues(AppLogic); + return ( + + } + /> + } + /> + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx new file mode 100644 index 000000000000..468f7d2f7ad0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx @@ -0,0 +1,75 @@ +/* + * 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 classNames from 'classnames'; +import { useValues } from 'kea'; + +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { CustomSourceIcon } from './custom_source_icon'; +import { TitleField } from './title_field'; + +export const ExampleResultDetailCard: React.FC = () => { + const { + sourceName, + searchResultConfig: { titleField, urlField, color, detailFields }, + titleFieldHover, + urlFieldHover, + exampleDocuments, + } = useValues(DisplaySettingsLogic); + + const result = exampleDocuments[0]; + + return ( +
    +
    +
    +
    + + + + + {sourceName} + +
    +
    + +
    + {urlField ? ( +
    {result[urlField]}
    + ) : ( + URL + )} +
    +
    +
    +
    + {detailFields.length > 0 ? ( + detailFields.map(({ fieldName, label }, index) => ( +
    + +

    {label}

    +
    + +
    {result[fieldName]}
    +
    +
    + )) + ) : ( + + )} +
    +
    + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx new file mode 100644 index 000000000000..14239b165430 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx @@ -0,0 +1,74 @@ +/* + * 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 { isColorDark, hexToRgb } from '@elastic/eui'; +import classNames from 'classnames'; +import { useValues } from 'kea'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { CustomSourceIcon } from './custom_source_icon'; +import { SubtitleField } from './subtitle_field'; +import { TitleField } from './title_field'; + +export const ExampleSearchResultGroup: React.FC = () => { + const { + sourceName, + searchResultConfig: { titleField, subtitleField, descriptionField, color }, + titleFieldHover, + subtitleFieldHover, + descriptionFieldHover, + exampleDocuments, + } = useValues(DisplaySettingsLogic); + + return ( +
    +
    + + + {sourceName} + +
    +
    +
    +
    + {exampleDocuments.map((result, id) => ( +
    +
    + + +
    + {descriptionField ? ( +
    {result[descriptionField]}
    + ) : ( + Description + )} +
    +
    +
    + ))} +
    +
    +
    + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx new file mode 100644 index 000000000000..4ef3b1fe14b9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx @@ -0,0 +1,66 @@ +/* + * 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 classNames from 'classnames'; +import { useValues } from 'kea'; + +import { isColorDark, hexToRgb } from '@elastic/eui'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { CustomSourceIcon } from './custom_source_icon'; +import { SubtitleField } from './subtitle_field'; +import { TitleField } from './title_field'; + +export const ExampleStandoutResult: React.FC = () => { + const { + sourceName, + searchResultConfig: { titleField, subtitleField, descriptionField, color }, + titleFieldHover, + subtitleFieldHover, + descriptionFieldHover, + exampleDocuments, + } = useValues(DisplaySettingsLogic); + + const result = exampleDocuments[0]; + + return ( +
    +
    + + + {sourceName} + +
    +
    +
    + + +
    + {descriptionField ? ( + {result[descriptionField]} + ) : ( + Description + )} +
    +
    +
    +
    + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx new file mode 100644 index 000000000000..587916a741d6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx @@ -0,0 +1,101 @@ +/* + * 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, { FormEvent, useState } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSelect, +} from '@elastic/eui'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +const emptyField = { fieldName: '', label: '' }; + +export const FieldEditorModal: React.FC = () => { + const { toggleFieldEditorModal, addDetailField, updateDetailField } = useActions( + DisplaySettingsLogic + ); + + const { + searchResultConfig: { detailFields }, + availableFieldOptions, + fieldOptions, + editFieldIndex, + } = useValues(DisplaySettingsLogic); + + const isEditing = editFieldIndex || editFieldIndex === 0; + const field = isEditing ? detailFields[editFieldIndex || 0] : emptyField; + const [fieldName, setName] = useState(field.fieldName || ''); + const [label, setLabel] = useState(field.label || ''); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (isEditing) { + updateDetailField({ fieldName, label }, editFieldIndex); + } else { + addDetailField({ fieldName, label }); + } + }; + + const ACTION_LABEL = isEditing ? 'Update' : 'Add'; + + return ( + +
    + + + {ACTION_LABEL} Field + + + + + setName(e.target.value)} + /> + + + setLabel(e.target.value)} + /> + + + + + Cancel + + {ACTION_LABEL} Field + + + +
    +
    + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx new file mode 100644 index 000000000000..cb65d8ef671e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx @@ -0,0 +1,146 @@ +/* + * 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 { useActions, useValues } from 'kea'; + +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiDragDropContext, + EuiDraggable, + EuiDroppable, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { ExampleResultDetailCard } from './example_result_detail_card'; + +export const ResultDetail: React.FC = () => { + const { + toggleFieldEditorModal, + setDetailFields, + openEditDetailField, + removeDetailField, + } = useActions(DisplaySettingsLogic); + + const { + searchResultConfig: { detailFields }, + availableFieldOptions, + } = useValues(DisplaySettingsLogic); + + return ( + <> + + + + + + + <> + + + +

    Visible Fields

    +
    +
    + + + Add Field + + +
    + + {detailFields.length > 0 ? ( + + + <> + {detailFields.map(({ fieldName, label }, index) => ( + + {(provided) => ( + + + +
    + +
    +
    + + +

    {fieldName}

    +
    + +
    “{label || ''}”
    +
    +
    + +
    + openEditDetailField(index)} + /> + removeDetailField(index)} + /> +
    +
    +
    +
    + )} +
    + ))} + +
    +
    + ) : ( +

    Add fields and move them into the order you want them to appear.

    + )} + +
    +
    +
    + + + +

    Preview

    +
    + + +
    +
    +
    + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx new file mode 100644 index 000000000000..cfe0ddb1533e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiColorPicker, + EuiFlexGrid, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiPanel, + EuiSelect, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { DisplaySettingsLogic } from './display_settings_logic'; + +import { ExampleSearchResultGroup } from './example_search_result_group'; +import { ExampleStandoutResult } from './example_standout_result'; + +export const SearchResults: React.FC = () => { + const { + toggleTitleFieldHover, + toggleSubtitleFieldHover, + toggleDescriptionFieldHover, + setTitleField, + setSubtitleField, + setDescriptionField, + setUrlField, + setColorField, + } = useActions(DisplaySettingsLogic); + + const { + searchResultConfig: { titleField, descriptionField, subtitleField, urlField, color }, + fieldOptions, + optionalFieldOptions, + } = useValues(DisplaySettingsLogic); + + return ( + <> + + + + + +

    Search Result Settings

    +
    + + + null} // FIXME + onBlur={() => null} // FIXME + > + setTitleField(e.target.value)} + /> + + + setUrlField(e.target.value)} + /> + + + null} // FIXME + onBlur={() => null} // FIXME + /> + + null} // FIXME + onBlur={() => null} // FIXME + > + setSubtitleField(value === '' ? null : value)} + /> + + null} // FIXME + onBlur={() => null} // FIXME + > + + setDescriptionField(value === '' ? null : value) + } + /> + + +
    + + + +

    Preview

    +
    + +
    + +

    Featured Results

    +
    +

    + A matching document will appear as a single bold card. +

    +
    + + + +
    + +

    Standard Results

    +
    +

    + Somewhat matching documents will appear as a set. +

    +
    + + +
    +
    +
    + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx new file mode 100644 index 000000000000..e27052ddffc0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import classNames from 'classnames'; + +import { Result } from '../../../../types'; + +interface SubtitleFieldProps { + result: Result; + subtitleField: string | null; + subtitleFieldHover: boolean; +} + +export const SubtitleField: React.FC = ({ + result, + subtitleField, + subtitleFieldHover, +}) => ( +
    + {subtitleField ? ( +
    {result[subtitleField]}
    + ) : ( + Subtitle + )} +
    +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx new file mode 100644 index 000000000000..a54c0977b464 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import classNames from 'classnames'; + +import { Result } from '../../../../types'; + +interface TitleFieldProps { + result: Result; + titleField: string | null; + titleFieldHover: boolean; +} + +export const TitleField: React.FC = ({ result, titleField, titleFieldHover }) => { + const title = titleField ? result[titleField] : ''; + const titleDisplay = Array.isArray(title) ? title.join(', ') : title; + return ( +
    + {titleField ? ( +
    {titleDisplay}
    + ) : ( + Title + )} +
    + ); +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts index 22e2deaace1d..62f4dceeac36 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts @@ -18,6 +18,7 @@ import { registerAccountPreSourceRoute, registerAccountPrepareSourcesRoute, registerAccountSourceSearchableRoute, + registerAccountSourceDisplaySettingsConfig, registerOrgSourcesRoute, registerOrgSourcesStatusRoute, registerOrgSourceRoute, @@ -29,6 +30,7 @@ import { registerOrgPreSourceRoute, registerOrgPrepareSourcesRoute, registerOrgSourceSearchableRoute, + registerOrgSourceDisplaySettingsConfig, registerOrgSourceOauthConfigurationsRoute, registerOrgSourceOauthConfigurationRoute, } from './sources'; @@ -446,6 +448,81 @@ describe('sources routes', () => { }); }); + describe('GET /api/workplace_search/account/sources/{id}/display_settings/config', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/account/sources/{id}/display_settings/config', + payload: 'params', + }); + + registerAccountSourceDisplaySettingsConfig({ + ...mockDependencies, + router: mockRouter.router, + }); + + const mockRequest = { + params: { + id: '123', + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/sources/123/display_settings/config', + }); + }); + }); + + describe('POST /api/workplace_search/account/sources/{id}/display_settings/config', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/account/sources/{id}/display_settings/config', + payload: 'body', + }); + + registerAccountSourceDisplaySettingsConfig({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + const mockRequest = { + params: { id: '123' }, + body: { + titleField: 'foo', + subtitleField: 'bar', + descriptionField: 'this is a thing', + urlField: 'http://youknowfor.search', + color: '#aaa', + detailFields: { + fieldName: 'myField', + label: 'My Field', + }, + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/sources/123/display_settings/config', + body: mockRequest.body, + }); + }); + }); + describe('GET /api/workplace_search/org/sources', () => { let mockRouter: MockRouter; @@ -848,6 +925,81 @@ describe('sources routes', () => { }); }); + describe('GET /api/workplace_search/org/sources/{id}/display_settings/config', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/sources/{id}/display_settings/config', + payload: 'params', + }); + + registerOrgSourceDisplaySettingsConfig({ + ...mockDependencies, + router: mockRouter.router, + }); + + const mockRequest = { + params: { + id: '123', + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/sources/123/display_settings/config', + }); + }); + }); + + describe('POST /api/workplace_search/org/sources/{id}/display_settings/config', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/workplace_search/org/sources/{id}/display_settings/config', + payload: 'body', + }); + + registerOrgSourceDisplaySettingsConfig({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + const mockRequest = { + params: { id: '123' }, + body: { + titleField: 'foo', + subtitleField: 'bar', + descriptionField: 'this is a thing', + urlField: 'http://youknowfor.search', + color: '#aaa', + detailFields: { + fieldName: 'myField', + label: 'My Field', + }, + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/sources/123/display_settings/config', + body: mockRequest.body, + }); + }); + }); + describe('GET /api/workplace_search/org/settings/connectors', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index 24473388c03b..d43a4252c7e1 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -25,6 +25,21 @@ const oAuthConfigSchema = schema.object({ consumer_key: schema.string(), }); +const displayFieldSchema = schema.object({ + fieldName: schema.string(), + label: schema.string(), +}); + +const displaySettingsSchema = schema.object({ + titleField: schema.maybe(schema.string()), + subtitleField: schema.maybe(schema.string()), + descriptionField: schema.maybe(schema.string()), + urlField: schema.maybe(schema.string()), + color: schema.string(), + urlFieldIsLinkable: schema.boolean(), + detailFields: schema.oneOf([schema.arrayOf(displayFieldSchema), displayFieldSchema]), +}); + export function registerAccountSourcesRoute({ router, enterpriseSearchRequestHandler, @@ -285,6 +300,45 @@ export function registerAccountSourceSearchableRoute({ ); } +export function registerAccountSourceDisplaySettingsConfig({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/account/sources/{id}/display_settings/config', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/sources/${request.params.id}/display_settings/config`, + })(context, request, response); + } + ); + + router.post( + { + path: '/api/workplace_search/account/sources/{id}/display_settings/config', + validate: { + body: displaySettingsSchema, + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/sources/${request.params.id}/display_settings/config`, + body: request.body, + })(context, request, response); + } + ); +} + export function registerOrgSourcesRoute({ router, enterpriseSearchRequestHandler, @@ -545,6 +599,45 @@ export function registerOrgSourceSearchableRoute({ ); } +export function registerOrgSourceDisplaySettingsConfig({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/sources/{id}/display_settings/config', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/sources/${request.params.id}/display_settings/config`, + })(context, request, response); + } + ); + + router.post( + { + path: '/api/workplace_search/org/sources/{id}/display_settings/config', + validate: { + body: displaySettingsSchema, + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/sources/${request.params.id}/display_settings/config`, + body: request.body, + })(context, request, response); + } + ); +} + export function registerOrgSourceOauthConfigurationsRoute({ router, enterpriseSearchRequestHandler, @@ -647,6 +740,7 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => { registerAccountPreSourceRoute(dependencies); registerAccountPrepareSourcesRoute(dependencies); registerAccountSourceSearchableRoute(dependencies); + registerAccountSourceDisplaySettingsConfig(dependencies); registerOrgSourcesRoute(dependencies); registerOrgSourcesStatusRoute(dependencies); registerOrgSourceRoute(dependencies); @@ -658,6 +752,7 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => { registerOrgPreSourceRoute(dependencies); registerOrgPrepareSourcesRoute(dependencies); registerOrgSourceSearchableRoute(dependencies); + registerOrgSourceDisplaySettingsConfig(dependencies); registerOrgSourceOauthConfigurationsRoute(dependencies); registerOrgSourceOauthConfigurationRoute(dependencies); }; From ea8ea4e4e0c62839ccf3d6c7315b9166b4a3a607 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 25 Nov 2020 08:03:47 -0700 Subject: [PATCH 66/89] [dev/cli] detect worker type using env, not cluster module (#83977) * [dev/cli] detect worker type using env, not cluster module * remove unused property * assume that if process.send is undefined we are not a child * update comment Co-authored-by: spalger Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-config/src/__mocks__/env.ts | 3 +-- .../kbn-config/src/__snapshots__/env.test.ts.snap | 12 ++++++------ packages/kbn-config/src/env.test.ts | 2 +- packages/kbn-config/src/env.ts | 8 ++++---- .../kbn-legacy-logging/src/log_format_string.ts | 4 ++-- packages/kbn-legacy-logging/src/rotate/index.ts | 7 ------- .../kbn-legacy-logging/src/rotate/log_rotator.ts | 13 ++----------- src/apm.js | 4 ---- src/cli/cluster/cluster_manager.ts | 2 -- src/cli/cluster/worker.ts | 4 +--- src/cli/serve/serve.js | 6 +++--- src/core/server/bootstrap.ts | 11 +++++------ src/core/server/http/http_service.test.ts | 2 +- src/core/server/http/http_service.ts | 2 +- src/core/server/legacy/legacy_service.test.ts | 4 ++-- src/core/server/legacy/legacy_service.ts | 8 +++----- src/core/server/plugins/plugins_service.test.ts | 8 ++++---- src/core/server/plugins/plugins_service.ts | 4 ++-- src/core/server/server.test.ts | 4 ++-- src/core/server/server.ts | 2 +- src/core/test_helpers/kbn_server.ts | 2 +- src/legacy/server/kbn_server.js | 3 +-- 22 files changed, 43 insertions(+), 72 deletions(-) diff --git a/packages/kbn-config/src/__mocks__/env.ts b/packages/kbn-config/src/__mocks__/env.ts index e0f6f6add168..8b7475680ecf 100644 --- a/packages/kbn-config/src/__mocks__/env.ts +++ b/packages/kbn-config/src/__mocks__/env.ts @@ -42,7 +42,6 @@ export function getEnvOptions(options: DeepPartial = {}): EnvOptions runExamples: false, ...(options.cliArgs || {}), }, - isDevClusterMaster: - options.isDevClusterMaster !== undefined ? options.isDevClusterMaster : false, + isDevCliParent: options.isDevCliParent !== undefined ? options.isDevCliParent : false, }; } diff --git a/packages/kbn-config/src/__snapshots__/env.test.ts.snap b/packages/kbn-config/src/__snapshots__/env.test.ts.snap index 884896266344..9236c83f9c92 100644 --- a/packages/kbn-config/src/__snapshots__/env.test.ts.snap +++ b/packages/kbn-config/src/__snapshots__/env.test.ts.snap @@ -22,7 +22,7 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevClusterMaster": false, + "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": true, @@ -67,7 +67,7 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevClusterMaster": false, + "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": false, @@ -111,7 +111,7 @@ Env { "/test/cwd/config/kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevClusterMaster": true, + "isDevCliParent": true, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": true, @@ -155,7 +155,7 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevClusterMaster": false, + "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": false, @@ -199,7 +199,7 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/test/kibanaRoot", - "isDevClusterMaster": false, + "isDevCliParent": false, "logDir": "/test/kibanaRoot/log", "mode": Object { "dev": false, @@ -243,7 +243,7 @@ Env { "/some/other/path/some-kibana.yml", ], "homeDir": "/some/home/dir", - "isDevClusterMaster": false, + "isDevCliParent": false, "logDir": "/some/home/dir/log", "mode": Object { "dev": false, diff --git a/packages/kbn-config/src/env.test.ts b/packages/kbn-config/src/env.test.ts index 1613a90951d4..5aee33e6fda5 100644 --- a/packages/kbn-config/src/env.test.ts +++ b/packages/kbn-config/src/env.test.ts @@ -47,7 +47,7 @@ test('correctly creates default environment in dev mode.', () => { REPO_ROOT, getEnvOptions({ configs: ['/test/cwd/config/kibana.yml'], - isDevClusterMaster: true, + isDevCliParent: true, }) ); diff --git a/packages/kbn-config/src/env.ts b/packages/kbn-config/src/env.ts index 7edb4b1c8dad..4ae8d7b7f9aa 100644 --- a/packages/kbn-config/src/env.ts +++ b/packages/kbn-config/src/env.ts @@ -26,7 +26,7 @@ import { PackageInfo, EnvironmentMode } from './types'; export interface EnvOptions { configs: string[]; cliArgs: CliArgs; - isDevClusterMaster: boolean; + isDevCliParent: boolean; } /** @internal */ @@ -101,10 +101,10 @@ export class Env { public readonly configs: readonly string[]; /** - * Indicates that this Kibana instance is run as development Node Cluster master. + * Indicates that this Kibana instance is running in the parent process of the dev cli. * @internal */ - public readonly isDevClusterMaster: boolean; + public readonly isDevCliParent: boolean; /** * @internal @@ -122,7 +122,7 @@ export class Env { this.cliArgs = Object.freeze(options.cliArgs); this.configs = Object.freeze(options.configs); - this.isDevClusterMaster = options.isDevClusterMaster; + this.isDevCliParent = options.isDevCliParent; const isDevMode = this.cliArgs.dev || this.cliArgs.envName === 'development'; this.mode = Object.freeze({ diff --git a/packages/kbn-legacy-logging/src/log_format_string.ts b/packages/kbn-legacy-logging/src/log_format_string.ts index 3f024fac5511..b4217c37b960 100644 --- a/packages/kbn-legacy-logging/src/log_format_string.ts +++ b/packages/kbn-legacy-logging/src/log_format_string.ts @@ -54,7 +54,7 @@ const type = _.memoize((t: string) => { return color(t)(_.pad(t, 7).slice(0, 7)); }); -const workerType = process.env.kbnWorkerType ? `${type(process.env.kbnWorkerType)} ` : ''; +const prefix = process.env.isDevCliChild ? `${type('server')} ` : ''; export class KbnLoggerStringFormat extends BaseLogFormat { format(data: Record) { @@ -71,6 +71,6 @@ export class KbnLoggerStringFormat extends BaseLogFormat { return s + `[${color(t)(t)}]`; }, ''); - return `${workerType}${type(data.type)} [${time}] ${tags} ${msg}`; + return `${prefix}${type(data.type)} [${time}] ${tags} ${msg}`; } } diff --git a/packages/kbn-legacy-logging/src/rotate/index.ts b/packages/kbn-legacy-logging/src/rotate/index.ts index 2387fc530e58..9a83c625b943 100644 --- a/packages/kbn-legacy-logging/src/rotate/index.ts +++ b/packages/kbn-legacy-logging/src/rotate/index.ts @@ -17,7 +17,6 @@ * under the License. */ -import { isMaster, isWorker } from 'cluster'; import { Server } from '@hapi/hapi'; import { LogRotator } from './log_rotator'; import { LegacyLoggingConfig } from '../schema'; @@ -30,12 +29,6 @@ export async function setupLoggingRotate(server: Server, config: LegacyLoggingCo return; } - // We just want to start the logging rotate service once - // and we choose to use the master (prod) or the worker server (dev) - if (!isMaster && isWorker && process.env.kbnWorkerType !== 'server') { - return; - } - // We don't want to run logging rotate server if // we are not logging to a file if (config.dest === 'stdout') { diff --git a/packages/kbn-legacy-logging/src/rotate/log_rotator.ts b/packages/kbn-legacy-logging/src/rotate/log_rotator.ts index 54181e30d600..fc2c088f01dd 100644 --- a/packages/kbn-legacy-logging/src/rotate/log_rotator.ts +++ b/packages/kbn-legacy-logging/src/rotate/log_rotator.ts @@ -18,7 +18,6 @@ */ import * as chokidar from 'chokidar'; -import { isMaster } from 'cluster'; import fs from 'fs'; import { Server } from '@hapi/hapi'; import { throttle } from 'lodash'; @@ -351,22 +350,14 @@ export class LogRotator { } _sendReloadLogConfigSignal() { - if (isMaster) { - (process as NodeJS.EventEmitter).emit('SIGHUP'); + if (!process.env.isDevCliChild || !process.send) { + process.emit('SIGHUP', 'SIGHUP'); return; } // Send a special message to the cluster manager // so it can forward it correctly // It will only run when we are under cluster mode (not under a production environment) - if (!process.send) { - this.log( - ['error', 'logging:rotate'], - 'For some unknown reason process.send is not defined, the rotation was not successful' - ); - return; - } - process.send(['RELOAD_LOGGING_CONFIG_FROM_SERVER_WORKER']); } } diff --git a/src/apm.js b/src/apm.js index bde37fa006c6..4f5fe29cbb5f 100644 --- a/src/apm.js +++ b/src/apm.js @@ -30,10 +30,6 @@ let apmConfig; const isKibanaDistributable = Boolean(build && build.distributable === true); module.exports = function (serviceName = name) { - if (process.env.kbnWorkerType === 'optmzr') { - return; - } - apmConfig = loadConfiguration(process.argv, ROOT_DIR, isKibanaDistributable); const conf = apmConfig.getConfig(serviceName); const apm = require('elastic-apm-node'); diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index f427c8750912..7a14f617b5d5 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -33,8 +33,6 @@ import { BasePathProxyServer } from '../../core/server/http'; import { Log } from './log'; import { Worker } from './worker'; -process.env.kbnWorkerType = 'managr'; - export type SomeCliArgs = Pick< CliArgs, | 'quiet' diff --git a/src/cli/cluster/worker.ts b/src/cli/cluster/worker.ts index d28065765070..26b2a643e537 100644 --- a/src/cli/cluster/worker.ts +++ b/src/cli/cluster/worker.ts @@ -56,7 +56,6 @@ export class Worker extends EventEmitter { private readonly clusterBinder: BinderFor; private readonly processBinder: BinderFor; - private type: string; private title: string; private log: any; private forkBinder: BinderFor | null = null; @@ -76,7 +75,6 @@ export class Worker extends EventEmitter { super(); this.log = opts.log; - this.type = opts.type; this.title = opts.title || opts.type; this.watch = opts.watch !== false; this.startCount = 0; @@ -88,7 +86,7 @@ export class Worker extends EventEmitter { this.env = { NODE_OPTIONS: process.env.NODE_OPTIONS || '', - kbnWorkerType: this.type, + isDevCliChild: 'true', kbnWorkerArgv: JSON.stringify([...(opts.baseArgv || baseArgv), ...(opts.argv || [])]), ELASTIC_APM_SERVICE_NAME: opts.apmServiceName || '', }; diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 2fa24cc7f379..61f880d80633 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -43,7 +43,7 @@ function canRequire(path) { } const CLUSTER_MANAGER_PATH = resolve(__dirname, '../cluster/cluster_manager'); -const CAN_CLUSTER = canRequire(CLUSTER_MANAGER_PATH); +const DEV_MODE_SUPPORTED = canRequire(CLUSTER_MANAGER_PATH); const REPL_PATH = resolve(__dirname, '../repl'); const CAN_REPL = canRequire(REPL_PATH); @@ -189,7 +189,7 @@ export default function (program) { ); } - if (CAN_CLUSTER) { + if (DEV_MODE_SUPPORTED) { command .option('--dev', 'Run the server with development mode defaults') .option('--ssl', 'Run the dev server using HTTPS') @@ -240,7 +240,7 @@ export default function (program) { dist: !!opts.dist, }, features: { - isClusterModeSupported: CAN_CLUSTER, + isCliDevModeSupported: DEV_MODE_SUPPORTED, isReplModeSupported: CAN_REPL, }, applyConfigOverrides: (rawConfig) => applyConfigOverrides(rawConfig, opts, unknownOptions), diff --git a/src/core/server/bootstrap.ts b/src/core/server/bootstrap.ts index ff1a5c0340c4..6711a8b8987e 100644 --- a/src/core/server/bootstrap.ts +++ b/src/core/server/bootstrap.ts @@ -18,16 +18,15 @@ */ import chalk from 'chalk'; -import { isMaster } from 'cluster'; import { CliArgs, Env, RawConfigService } from './config'; import { Root } from './root'; import { CriticalError } from './errors'; interface KibanaFeatures { - // Indicates whether we can run Kibana in a so called cluster mode in which - // Kibana is run as a "worker" process together with optimizer "worker" process - // that are orchestrated by the "master" process (dev mode only feature). - isClusterModeSupported: boolean; + // Indicates whether we can run Kibana in dev mode in which Kibana is run as + // a child process together with optimizer "worker" processes that are + // orchestrated by a parent process (dev mode only feature). + isCliDevModeSupported: boolean; // Indicates whether we can run Kibana in REPL mode (dev mode only feature). isReplModeSupported: boolean; @@ -71,7 +70,7 @@ export async function bootstrap({ const env = Env.createDefault(REPO_ROOT, { configs, cliArgs, - isDevClusterMaster: isMaster && cliArgs.dev && features.isClusterModeSupported, + isDevCliParent: cliArgs.dev && features.isCliDevModeSupported && !process.env.isDevCliChild, }); const rawConfigService = new RawConfigService(env.configs, applyConfigOverrides); diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 11cea88fa0dd..3d5532246128 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -264,7 +264,7 @@ test('does not start http server if process is dev cluster master', async () => const service = new HttpService({ coreId, configService, - env: Env.createDefault(REPO_ROOT, getEnvOptions({ isDevClusterMaster: true })), + env: Env.createDefault(REPO_ROOT, getEnvOptions({ isDevCliParent: true })), logger, }); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 0127a6493e7f..171a20160d26 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -158,7 +158,7 @@ export class HttpService * @internal * */ private shouldListen(config: HttpConfig) { - return !this.coreContext.env.isDevClusterMaster && config.autoListen; + return !this.coreContext.env.isDevCliParent && config.autoListen; } public async stop() { diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 5cc6fcb13350..fe19ef9d0a77 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -362,7 +362,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { REPO_ROOT, getEnvOptions({ cliArgs: { silent: true, basePath: false }, - isDevClusterMaster: true, + isDevCliParent: true, }) ), logger, @@ -391,7 +391,7 @@ describe('once LegacyService is set up in `devClusterMaster` mode', () => { REPO_ROOT, getEnvOptions({ cliArgs: { quiet: true, basePath: true }, - isDevClusterMaster: true, + isDevCliParent: true, }) ), logger, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 3111c8daf798..4ae6c9d43757 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -144,7 +144,7 @@ export class LegacyService implements CoreService { this.log.debug('starting legacy service'); // Receive initial config and create kbnServer/ClusterManager. - if (this.coreContext.env.isDevClusterMaster) { + if (this.coreContext.env.isDevCliParent) { await this.createClusterManager(this.legacyRawConfig!); } else { this.kbnServer = await this.createKbnServer( @@ -310,10 +310,8 @@ export class LegacyService implements CoreService { logger: this.coreContext.logger, }); - // The kbnWorkerType check is necessary to prevent the repl - // from being started multiple times in different processes. - // We only want one REPL. - if (this.coreContext.env.cliArgs.repl && process.env.kbnWorkerType === 'server') { + // Prevent the repl from being started multiple times in different processes. + if (this.coreContext.env.cliArgs.repl && process.env.isDevCliChild) { // eslint-disable-next-line @typescript-eslint/no-var-requires require('./cli').startRepl(kbnServer); } diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts index 02b82c17ed4f..601e0038b0cf 100644 --- a/src/core/server/plugins/plugins_service.test.ts +++ b/src/core/server/plugins/plugins_service.test.ts @@ -102,7 +102,7 @@ const createPlugin = ( }); }; -async function testSetup(options: { isDevClusterMaster?: boolean } = {}) { +async function testSetup(options: { isDevCliParent?: boolean } = {}) { mockPackage.raw = { branch: 'feature-v1', version: 'v1', @@ -116,7 +116,7 @@ async function testSetup(options: { isDevClusterMaster?: boolean } = {}) { coreId = Symbol('core'); env = Env.createDefault(REPO_ROOT, { ...getEnvOptions(), - isDevClusterMaster: options.isDevClusterMaster ?? false, + isDevCliParent: options.isDevCliParent ?? false, }); config$ = new BehaviorSubject>({ plugins: { initialize: true } }); @@ -638,10 +638,10 @@ describe('PluginsService', () => { }); }); -describe('PluginService when isDevClusterMaster is true', () => { +describe('PluginService when isDevCliParent is true', () => { beforeEach(async () => { await testSetup({ - isDevClusterMaster: true, + isDevCliParent: true, }); }); diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 5967e6d5358d..e1622b1e1923 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -90,7 +90,7 @@ export class PluginsService implements CoreService(); - const initialize = config.initialize && !this.coreContext.env.isDevClusterMaster; + const initialize = config.initialize && !this.coreContext.env.isDevCliParent; if (initialize) { contracts = await this.pluginsSystem.setupPlugins(deps); this.registerPluginStaticDirs(deps); diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 0c7ebbcb527e..f377bfc32173 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -216,10 +216,10 @@ test(`doesn't setup core services if legacy config validation fails`, async () = expect(mockI18nService.setup).not.toHaveBeenCalled(); }); -test(`doesn't validate config if env.isDevClusterMaster is true`, async () => { +test(`doesn't validate config if env.isDevCliParent is true`, async () => { const devParentEnv = Env.createDefault(REPO_ROOT, { ...getEnvOptions(), - isDevClusterMaster: true, + isDevCliParent: true, }); const server = new Server(rawConfigService, devParentEnv, logger); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 0f7e8cced999..e253663d8dc8 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -124,7 +124,7 @@ export class Server { const legacyConfigSetup = await this.legacy.setupLegacyConfig(); // rely on dev server to validate config, don't validate in the parent process - if (!this.env.isDevClusterMaster) { + if (!this.env.isDevCliParent) { // Immediately terminate in case of invalid configuration // This needs to be done after plugin discovery await this.configService.validate(); diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index f95ea66d3cbc..3161420b94d2 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -82,7 +82,7 @@ export function createRootWithSettings( dist: false, ...cliArgs, }, - isDevClusterMaster: false, + isDevCliParent: false, }); return new Root( diff --git a/src/legacy/server/kbn_server.js b/src/legacy/server/kbn_server.js index b61a86326ca1..85d75b4e1877 100644 --- a/src/legacy/server/kbn_server.js +++ b/src/legacy/server/kbn_server.js @@ -20,7 +20,6 @@ import { constant, once, compact, flatten } from 'lodash'; import { reconfigureLogging } from '@kbn/legacy-logging'; -import { isWorker } from 'cluster'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { fromRoot, pkg } from '../../core/server/utils'; import { Config } from './config'; @@ -121,7 +120,7 @@ export default class KbnServer { const { server, config } = this; - if (isWorker) { + if (process.env.isDevCliChild) { // help parent process know when we are ready process.send(['WORKER_LISTENING']); } From 280ce7e5fa9256b79e7900c3b3b898d3048d5ddd Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Wed, 25 Nov 2020 16:49:45 +0100 Subject: [PATCH 67/89] [ML] Persisted URL state for Anomalies table (#84314) * [ML] Persisted URL state for Anomalies table * [ML] adjust cell selection according to the time range --- .../anomalies_table/anomalies_table.js | 66 +++++++++++++++---- .../explorer/hooks/use_selected_cells.ts | 53 +++++++++++++-- .../reducers/explorer_reducer/state.ts | 3 +- .../application/routing/routes/explorer.tsx | 7 +- .../ml/public/application/util/url_state.tsx | 2 +- 5 files changed, 111 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js index 0a2c67a3b0dc..ebc782fe4625 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/anomalies_table.js @@ -25,8 +25,9 @@ import { mlTableService } from '../../services/table_service'; import { RuleEditorFlyout } from '../rule_editor'; import { ml } from '../../services/ml_api_service'; import { INFLUENCERS_LIMIT, ANOMALIES_TABLE_TABS, MAX_CHARS } from './anomalies_table_constants'; +import { usePageUrlState } from '../../util/url_state'; -class AnomaliesTable extends Component { +export class AnomaliesTableInternal extends Component { constructor(props) { super(props); @@ -145,8 +146,20 @@ class AnomaliesTable extends Component { }); }; + onTableChange = ({ page, sort }) => { + const { tableState, updateTableState } = this.props; + const result = { + pageIndex: page && page.index !== undefined ? page.index : tableState.pageIndex, + pageSize: page && page.size !== undefined ? page.size : tableState.pageSize, + sortField: sort && sort.field !== undefined ? sort.field : tableState.sortField, + sortDirection: + sort && sort.direction !== undefined ? sort.direction : tableState.sortDirection, + }; + updateTableState(result); + }; + render() { - const { bounds, tableData, filter, influencerFilter } = this.props; + const { bounds, tableData, filter, influencerFilter, tableState } = this.props; if ( tableData === undefined || @@ -186,8 +199,8 @@ class AnomaliesTable extends Component { const sorting = { sort: { - field: 'severity', - direction: 'desc', + field: tableState.sortField, + direction: tableState.sortDirection, }, }; @@ -199,8 +212,15 @@ class AnomaliesTable extends Component { }; }; + const pagination = { + pageIndex: tableState.pageIndex, + pageSize: tableState.pageSize, + totalItemCount: tableData.anomalies.length, + pageSizeOptions: [10, 25, 100], + }; + return ( - + <> - + ); } } -AnomaliesTable.propTypes = { + +export const getDefaultAnomaliesTableState = () => ({ + pageIndex: 0, + pageSize: 25, + sortField: 'severity', + sortDirection: 'desc', +}); + +export const AnomaliesTable = (props) => { + const [tableState, updateTableState] = usePageUrlState( + 'mlAnomaliesTable', + getDefaultAnomaliesTableState() + ); + return ( + + ); +}; + +AnomaliesTableInternal.propTypes = { bounds: PropTypes.object.isRequired, tableData: PropTypes.object, filter: PropTypes.func, influencerFilter: PropTypes.func, + tableState: PropTypes.object.isRequired, + updateTableState: PropTypes.func.isRequired, }; - -export { AnomaliesTable }; diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts index 7602954b4c8c..f940fdc2387e 100644 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts @@ -4,14 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { Duration } from 'moment'; import { SWIMLANE_TYPE } from '../explorer_constants'; -import { AppStateSelectedCells } from '../explorer_utils'; +import { AppStateSelectedCells, TimeRangeBounds } from '../explorer_utils'; import { ExplorerAppState } from '../../../../common/types/ml_url_generator'; export const useSelectedCells = ( appState: ExplorerAppState, - setAppState: (update: Partial) => void + setAppState: (update: Partial) => void, + timeBounds: TimeRangeBounds | undefined, + bucketInterval: Duration | undefined ): [AppStateSelectedCells | undefined, (swimlaneSelectedCells: AppStateSelectedCells) => void] => { // keep swimlane selection, restore selectedCells from AppState const selectedCells = useMemo(() => { @@ -28,7 +31,7 @@ export const useSelectedCells = ( }, [JSON.stringify(appState?.mlExplorerSwimlane)]); const setSelectedCells = useCallback( - (swimlaneSelectedCells: AppStateSelectedCells) => { + (swimlaneSelectedCells?: AppStateSelectedCells) => { const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane, } as ExplorerAppState['mlExplorerSwimlane']; @@ -65,5 +68,47 @@ export const useSelectedCells = ( [appState?.mlExplorerSwimlane, selectedCells, setAppState] ); + /** + * Adjust cell selection with respect to the time boundaries. + * Reset it entirely when it out of range. + */ + useEffect(() => { + if ( + timeBounds === undefined || + selectedCells?.times === undefined || + bucketInterval === undefined + ) + return; + + let [selectedFrom, selectedTo] = selectedCells.times; + + const rangeFrom = timeBounds.min!.unix(); + /** + * Because each cell on the swim lane represent the fixed bucket interval, + * the selection range could be outside of the time boundaries with + * correction within the bucket interval. + */ + const rangeTo = timeBounds.max!.unix() + bucketInterval.asSeconds(); + + selectedFrom = Math.max(selectedFrom, rangeFrom); + + selectedTo = Math.min(selectedTo, rangeTo); + + const isSelectionOutOfRange = rangeFrom > selectedTo || rangeTo < selectedFrom; + + if (isSelectionOutOfRange) { + // reset selection + setSelectedCells(); + return; + } + + if (selectedFrom !== rangeFrom || selectedTo !== rangeTo) { + setSelectedCells({ + ...selectedCells, + times: [selectedFrom, selectedTo], + }); + } + }, [timeBounds, selectedCells, bucketInterval]); + return [selectedCells, setSelectedCells]; }; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index ea9a8b5c1805..14b0a6033999 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Duration } from 'moment'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; import { Dictionary } from '../../../../../common/types/common'; @@ -43,7 +44,7 @@ export interface ExplorerState { queryString: string; selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[] | null; - swimlaneBucketInterval: any; + swimlaneBucketInterval: Duration | undefined; swimlaneContainerWidth: number; tableData: AnomaliesTableData; tableQueryString: string; diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index f8b4de6903ad..2126cbceae6b 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -205,7 +205,12 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const [tableInterval] = useTableInterval(); const [tableSeverity] = useTableSeverity(); - const [selectedCells, setSelectedCells] = useSelectedCells(explorerUrlState, setExplorerUrlState); + const [selectedCells, setSelectedCells] = useSelectedCells( + explorerUrlState, + setExplorerUrlState, + explorerState?.bounds, + explorerState?.swimlaneBucketInterval + ); useEffect(() => { explorerService.setSelectedCells(selectedCells); diff --git a/x-pack/plugins/ml/public/application/util/url_state.tsx b/x-pack/plugins/ml/public/application/util/url_state.tsx index fdc6dd135cd6..6cdc069096dc 100644 --- a/x-pack/plugins/ml/public/application/util/url_state.tsx +++ b/x-pack/plugins/ml/public/application/util/url_state.tsx @@ -162,7 +162,7 @@ export const useUrlState = (accessor: Accessor) => { return [urlState, setUrlState]; }; -type AppStateKey = 'mlSelectSeverity' | 'mlSelectInterval' | MlPages; +type AppStateKey = 'mlSelectSeverity' | 'mlSelectInterval' | 'mlAnomaliesTable' | MlPages; /** * Hook for managing the URL state of the page. From 5b2e119356c33543a20283a64e1f7ae8555a1fc3 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 25 Nov 2020 16:57:03 +0100 Subject: [PATCH 68/89] add live region for field search (#84310) --- .../datapanel.test.tsx | 19 +++++++++++++++++++ .../indexpattern_datasource/datapanel.tsx | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index ac82caf9d522..3d55494fd260 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -718,6 +718,25 @@ describe('IndexPattern Data Panel', () => { ]); }); + it('should announce filter in live region', () => { + const wrapper = mountWithIntl(); + act(() => { + wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').prop('onChange')!({ + target: { value: 'me' }, + } as ChangeEvent); + }); + + wrapper + .find('[data-test-subj="lnsIndexPatternEmptyFields"]') + .find('button') + .first() + .simulate('click'); + + expect(wrapper.find('[aria-live="polite"]').text()).toEqual( + '1 available field. 1 empty field. 0 meta fields.' + ); + }); + it('should filter down by type', () => { const wrapper = mountWithIntl(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index f2c7d7fc2092..ad5509dd88bc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -18,10 +18,12 @@ import { EuiSpacer, EuiFilterGroup, EuiFilterButton, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { DataPublicPluginStart, EsQueryConfig, Query, Filter } from 'src/plugins/data/public'; +import { htmlIdGenerator } from '@elastic/eui'; import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; import { ChildDragDropProvider, DragContextState } from '../drag_drop'; import { @@ -222,6 +224,9 @@ const fieldFiltersLabel = i18n.translate('xpack.lens.indexPatterns.fieldFiltersL defaultMessage: 'Field filters', }); +const htmlId = htmlIdGenerator('datapanel'); +const fieldSearchDescriptionId = htmlId(); + export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ currentIndexPatternId, indexPatternRefs, @@ -489,6 +494,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameAriaLabel', { defaultMessage: 'Search fields', })} + aria-describedby={fieldSearchDescriptionId} /> @@ -550,6 +556,19 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ + +
    + {i18n.translate('xpack.lens.indexPatterns.fieldSearchLiveRegion', { + defaultMessage: + '{availableFields} available {availableFields, plural, one {field} other {fields}}. {emptyFields} empty {emptyFields, plural, one {field} other {fields}}. {metaFields} meta {metaFields, plural, one {field} other {fields}}.', + values: { + availableFields: fieldGroups.AvailableFields.fields.length, + emptyFields: fieldGroups.EmptyFields.fields.length, + metaFields: fieldGroups.MetaFields.fields.length, + }, + })} +
    +
    From 9bef42b2a8078d070b206c6860c65df638d3b323 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Wed, 25 Nov 2020 10:59:52 -0500 Subject: [PATCH 69/89] redirect to visualize listing page when by value visualization editor doesn't have a value input (#84287) --- .../application/components/visualize_byvalue_editor.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx b/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx index a63f597f1013..1c1eb9956a32 100644 --- a/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx +++ b/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx @@ -33,6 +33,7 @@ import { import { VisualizeServices } from '../types'; import { VisualizeEditorCommon } from './visualize_editor_common'; import { VisualizeAppProps } from '../app'; +import { VisualizeConstants } from '../..'; export const VisualizeByValueEditor = ({ onAppLeave }: VisualizeAppProps) => { const [originatingApp, setOriginatingApp] = useState(); @@ -52,7 +53,8 @@ export const VisualizeByValueEditor = ({ onAppLeave }: VisualizeAppProps) => { setValueInput(valueInputValue); setEmbeddableId(embeddableIdValue); if (!valueInputValue) { - history.back(); + // if there is no value input to load, redirect to the visualize listing page. + services.history.replace(VisualizeConstants.LANDING_PAGE_PATH); } }, [services]); From 351cd6d19d0367ee606f4dd32aac4ee4190844ae Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Wed, 25 Nov 2020 11:03:00 -0500 Subject: [PATCH 70/89] [Time to Visualize] Fix Unlink Action via Rollback of ReplacePanel (#83873) * Fixed unlink action via rollback of replacePanel changes --- .../actions/add_to_library_action.test.tsx | 18 +++++++--- .../actions/add_to_library_action.tsx | 5 ++- .../unlink_from_library_action.test.tsx | 18 +++++++--- .../actions/unlink_from_library_action.tsx | 5 ++- .../embeddable/dashboard_container.tsx | 33 ++++++++++++++++--- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx index feb30b248c06..5f3945e73352 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx @@ -137,12 +137,17 @@ test('Add to library is not compatible when embeddable is not in a dashboard con test('Add to library replaces embeddableId and retains panel count', async () => { const dashboard = embeddable.getRoot() as IContainer; const originalPanelCount = Object.keys(dashboard.getInput().panels).length; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); - expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); - const newPanel = container.getInput().panels[embeddable.id!]; + + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; expect(newPanel.type).toEqual(embeddable.type); }); @@ -158,10 +163,15 @@ test('Add to library returns reference type input', async () => { mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id } as EmbeddableInput, }); + const dashboard = embeddable.getRoot() as IContainer; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new AddToLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); - expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); - const newPanel = container.getInput().panels[embeddable.id!]; + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; expect(newPanel.type).toEqual(embeddable.type); expect(newPanel.explicitInput.attributes).toBeUndefined(); expect(newPanel.explicitInput.savedObjectId).toBe('testSavedObjectId'); diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx index 179e5d522a2b..08cd0c7a1538 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx @@ -19,7 +19,6 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import uuid from 'uuid'; import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; import { @@ -89,9 +88,9 @@ export class AddToLibraryAction implements ActionByType = { type: embeddable.type, - explicitInput: { ...newInput, id: uuid.v4() }, + explicitInput: { ...newInput }, }; - dashboard.replacePanel(panelToReplace, newPanel); + dashboard.replacePanel(panelToReplace, newPanel, true); const title = i18n.translate('dashboard.panel.addToLibrary.successMessage', { defaultMessage: `Panel '{panelTitle}' was added to the visualize library`, diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx index f191be6f7baa..6a9769b0c8d1 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -135,11 +135,16 @@ test('Unlink is not compatible when embeddable is not in a dashboard container', test('Unlink replaces embeddableId and retains panel count', async () => { const dashboard = embeddable.getRoot() as IContainer; const originalPanelCount = Object.keys(dashboard.getInput().panels).length; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); - expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); - const newPanel = container.getInput().panels[embeddable.id!]; + + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; expect(newPanel.type).toEqual(embeddable.type); }); @@ -159,10 +164,15 @@ test('Unlink unwraps all attributes from savedObject', async () => { mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id }, }); + const dashboard = embeddable.getRoot() as IContainer; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); const action = new UnlinkFromLibraryAction({ toasts: coreStart.notifications.toasts }); await action.execute({ embeddable }); - expect(Object.keys(container.getInput().panels)).toContain(embeddable.id); - const newPanel = container.getInput().panels[embeddable.id!]; + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; expect(newPanel.type).toEqual(embeddable.type); expect(newPanel.explicitInput.attributes).toEqual(complicatedAttributes); }); diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx index 5e1614536471..b20bbc6350aa 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -19,7 +19,6 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import uuid from 'uuid'; import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; import { @@ -88,9 +87,9 @@ export class UnlinkFromLibraryAction implements ActionByType = { type: embeddable.type, - explicitInput: { ...newInput, id: uuid.v4() }, + explicitInput: { ...newInput }, }; - dashboard.replacePanel(panelToReplace, newPanel); + dashboard.replacePanel(panelToReplace, newPanel, true); const title = embeddable.getTitle() ? i18n.translate('dashboard.panel.unlinkFromLibrary.successMessageWithTitle', { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 051a7ef8bfb9..e80d387fa306 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -173,11 +173,30 @@ export class DashboardContainer extends Container, - newPanelState: Partial + newPanelState: Partial, + generateNewId?: boolean ) { - // Because the embeddable type can change, we have to operate at the container level here - return this.updateInput({ - panels: { + let panels; + if (generateNewId) { + // replace panel can be called with generateNewId in order to totally destroy and recreate the embeddable + panels = { ...this.input.panels }; + delete panels[previousPanelState.explicitInput.id]; + const newId = uuid.v4(); + panels[newId] = { + ...previousPanelState, + ...newPanelState, + gridData: { + ...previousPanelState.gridData, + i: newId, + }, + explicitInput: { + ...newPanelState.explicitInput, + id: newId, + }, + }; + } else { + // Because the embeddable type can change, we have to operate at the container level here + panels = { ...this.input.panels, [previousPanelState.explicitInput.id]: { ...previousPanelState, @@ -190,7 +209,11 @@ export class DashboardContainer extends Container Date: Wed, 25 Nov 2020 17:08:15 +0100 Subject: [PATCH 71/89] [APM] Elastic chart issues (#84238) * fixing charts * addressing pr comments --- .../ErrorGroupDetails/Distribution/index.tsx | 7 +- .../TransactionDetails/Distribution/index.tsx | 2 +- .../shared/charts/annotations/index.tsx | 45 ------- .../shared/charts/timeseries_chart.tsx | 38 +++++- .../transaction_breakdown_chart_contents.tsx | 36 +++++- .../charts/transaction_charts/index.tsx | 119 +++++++++--------- .../transaction_error_rate_chart/index.tsx | 1 + .../public/context/annotations_context.tsx | 49 ++++++++ .../apm/public/hooks/use_annotations.ts | 34 ++--- 9 files changed, 194 insertions(+), 137 deletions(-) delete mode 100644 x-pack/plugins/apm/public/components/shared/charts/annotations/index.tsx create mode 100644 x-pack/plugins/apm/public/context/annotations_context.tsx diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index 99316e3520a7..159f111bee04 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -90,7 +90,12 @@ export function ErrorDistribution({ distribution, title }: Props) { showOverlappingTicks tickFormat={xFormatter} /> - + formatYShort(value)} /> ({ - dataValue: annotation['@timestamp'], - header: asAbsoluteDateTime(annotation['@timestamp']), - details: `${i18n.translate('xpack.apm.chart.annotation.version', { - defaultMessage: 'Version', - })} ${annotation.text}`, - }))} - style={{ line: { strokeWidth: 1, stroke: color, opacity: 1 } }} - marker={} - markerPosition={Position.Top} - /> - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx index c4f5abe104aa..ea6f2a4a233e 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx @@ -5,28 +5,35 @@ */ import { + AnnotationDomainTypes, AreaSeries, Axis, Chart, CurveType, LegendItemListener, + LineAnnotation, LineSeries, niceTimeFormatter, Placement, Position, ScaleType, Settings, + YDomainRange, } from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { useChartTheme } from '../../../../../observability/public'; +import { asAbsoluteDateTime } from '../../../../common/utils/formatters'; import { TimeSeries } from '../../../../typings/timeseries'; import { FETCH_STATUS } from '../../../hooks/useFetcher'; +import { useTheme } from '../../../hooks/useTheme'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useAnnotations } from '../../../hooks/use_annotations'; import { useChartPointerEvent } from '../../../hooks/use_chart_pointer_event'; import { unit } from '../../../style/variables'; -import { Annotations } from './annotations'; import { ChartContainer } from './chart_container'; import { onBrushEnd } from './helper/helper'; @@ -45,6 +52,7 @@ interface Props { */ yTickFormat?: (y: number) => string; showAnnotations?: boolean; + yDomain?: YDomainRange; } export function TimeseriesChart({ @@ -56,12 +64,16 @@ export function TimeseriesChart({ yLabelFormat, yTickFormat, showAnnotations = true, + yDomain, }: Props) { const history = useHistory(); const chartRef = React.createRef(); + const { annotations } = useAnnotations(); const chartTheme = useChartTheme(); const { pointerEvent, setPointerEvent } = useChartPointerEvent(); const { urlParams } = useUrlParams(); + const theme = useTheme(); + const { start, end } = urlParams; useEffect(() => { @@ -83,6 +95,8 @@ export function TimeseriesChart({ y === null || y === undefined ); + const annotationColor = theme.eui.euiColorSecondary; + return ( @@ -108,17 +122,35 @@ export function TimeseriesChart({ position={Position.Bottom} showOverlappingTicks tickFormat={xFormatter} + gridLine={{ visible: false }} /> - {showAnnotations && } + {showAnnotations && ( + ({ + dataValue: annotation['@timestamp'], + header: asAbsoluteDateTime(annotation['@timestamp']), + details: `${i18n.translate('xpack.apm.chart.annotation.version', { + defaultMessage: 'Version', + })} ${annotation.text}`, + }))} + style={{ + line: { strokeWidth: 1, stroke: annotationColor, opacity: 1 }, + }} + marker={} + markerPosition={Position.Top} + /> + )} {timeseries.map((serie) => { const Series = serie.type === 'area' ? AreaSeries : LineSeries; diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx index 04c07c01442a..20056a6831ad 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx @@ -5,27 +5,35 @@ */ import { + AnnotationDomainTypes, AreaSeries, Axis, Chart, CurveType, + LineAnnotation, niceTimeFormatter, Placement, Position, ScaleType, Settings, } from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { useChartTheme } from '../../../../../../observability/public'; -import { asPercent } from '../../../../../common/utils/formatters'; +import { + asAbsoluteDateTime, + asPercent, +} from '../../../../../common/utils/formatters'; import { TimeSeries } from '../../../../../typings/timeseries'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { useTheme } from '../../../../hooks/useTheme'; import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { useAnnotations } from '../../../../hooks/use_annotations'; import { useChartPointerEvent } from '../../../../hooks/use_chart_pointer_event'; import { unit } from '../../../../style/variables'; -import { Annotations } from '../../charts/annotations'; import { ChartContainer } from '../../charts/chart_container'; import { onBrushEnd } from '../../charts/helper/helper'; @@ -44,9 +52,11 @@ export function TransactionBreakdownChartContents({ }: Props) { const history = useHistory(); const chartRef = React.createRef(); + const { annotations } = useAnnotations(); const chartTheme = useChartTheme(); const { pointerEvent, setPointerEvent } = useChartPointerEvent(); const { urlParams } = useUrlParams(); + const theme = useTheme(); const { start, end } = urlParams; useEffect(() => { @@ -64,6 +74,8 @@ export function TransactionBreakdownChartContents({ const xFormatter = niceTimeFormatter([min, max]); + const annotationColor = theme.eui.euiColorSecondary; + return ( @@ -85,6 +97,7 @@ export function TransactionBreakdownChartContents({ position={Position.Bottom} showOverlappingTicks tickFormat={xFormatter} + gridLine={{ visible: false }} /> asPercent(y ?? 0, 1)} /> - {showAnnotations && } + {showAnnotations && ( + ({ + dataValue: annotation['@timestamp'], + header: asAbsoluteDateTime(annotation['@timestamp']), + details: `${i18n.translate('xpack.apm.chart.annotation.version', { + defaultMessage: 'Version', + })} ${annotation.text}`, + }))} + style={{ + line: { strokeWidth: 1, stroke: annotationColor, opacity: 1 }, + }} + marker={} + markerPosition={Position.Top} + /> + )} {timeseries?.length ? ( timeseries.map((serie) => { diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index 221f17bb9e1d..3f8071ec39f0 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -20,13 +20,14 @@ import { TRANSACTION_ROUTE_CHANGE, } from '../../../../../common/transaction_types'; import { asTransactionRate } from '../../../../../common/utils/formatters'; +import { AnnotationsContextProvider } from '../../../../context/annotations_context'; import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event_context'; import { LicenseContext } from '../../../../context/LicenseContext'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { ITransactionChartData } from '../../../../selectors/chart_selectors'; -import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; import { TimeseriesChart } from '../timeseries_chart'; +import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; import { getResponseTimeTickFormatter } from './helper'; import { MLHeader } from './ml_header'; @@ -51,65 +52,69 @@ export function TransactionCharts({ return ( <> - - - - - - - - {responseTimeLabel(transactionType)} - - - - {(license) => ( - - )} - - - { - if (serie) { - toggleSerie(serie); - } - }} - /> - - + + + + + + + + + {responseTimeLabel(transactionType)} + + + + {(license) => ( + + )} + + + { + if (serie) { + toggleSerie(serie); + } + }} + /> + + - - - - {tpmLabel(transactionType)} - - - - - + + + + {tpmLabel(transactionType)} + + + + + - + - - - - - - - - - + + + + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx index b9028ff2e9e8..00472df95c4b 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx @@ -91,6 +91,7 @@ export function TransactionErrorRateChart({ ]} yLabelFormat={yLabelFormat} yTickFormat={yTickFormat} + yDomain={{ min: 0, max: 1 }} /> ); diff --git a/x-pack/plugins/apm/public/context/annotations_context.tsx b/x-pack/plugins/apm/public/context/annotations_context.tsx new file mode 100644 index 000000000000..4e09a3d227b1 --- /dev/null +++ b/x-pack/plugins/apm/public/context/annotations_context.tsx @@ -0,0 +1,49 @@ +/* + * 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, { createContext } from 'react'; +import { useParams } from 'react-router-dom'; +import { Annotation } from '../../common/annotations'; +import { useFetcher } from '../hooks/useFetcher'; +import { useUrlParams } from '../hooks/useUrlParams'; +import { callApmApi } from '../services/rest/createCallApmApi'; + +export const AnnotationsContext = createContext({ annotations: [] } as { + annotations: Annotation[]; +}); + +const INITIAL_STATE = { annotations: [] }; + +export function AnnotationsContextProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { serviceName } = useParams<{ serviceName?: string }>(); + const { urlParams, uiFilters } = useUrlParams(); + const { start, end } = urlParams; + const { environment } = uiFilters; + + const { data = INITIAL_STATE } = useFetcher(() => { + if (start && end && serviceName) { + return callApmApi({ + endpoint: 'GET /api/apm/services/{serviceName}/annotation/search', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment, + }, + }, + }); + } + }, [start, end, environment, serviceName]); + + return ; +} diff --git a/x-pack/plugins/apm/public/hooks/use_annotations.ts b/x-pack/plugins/apm/public/hooks/use_annotations.ts index e8f6785706a9..1cd9a7e65dda 100644 --- a/x-pack/plugins/apm/public/hooks/use_annotations.ts +++ b/x-pack/plugins/apm/public/hooks/use_annotations.ts @@ -3,36 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { useParams } from 'react-router-dom'; -import { callApmApi } from '../services/rest/createCallApmApi'; -import { useFetcher } from './useFetcher'; -import { useUrlParams } from './useUrlParams'; -const INITIAL_STATE = { annotations: [] }; +import { useContext } from 'react'; +import { AnnotationsContext } from '../context/annotations_context'; export function useAnnotations() { - const { serviceName } = useParams<{ serviceName?: string }>(); - const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; - const { environment } = uiFilters; + const context = useContext(AnnotationsContext); - const { data = INITIAL_STATE } = useFetcher(() => { - if (start && end && serviceName) { - return callApmApi({ - endpoint: 'GET /api/apm/services/{serviceName}/annotation/search', - params: { - path: { - serviceName, - }, - query: { - start, - end, - environment, - }, - }, - }); - } - }, [start, end, environment, serviceName]); + if (!context) { + throw new Error('Missing Annotations context provider'); + } - return data; + return context; } From 284b1046df6981f0da1e743680bf99247523627e Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 25 Nov 2020 10:13:36 -0800 Subject: [PATCH 72/89] [Fleet] Support input-level vars & templates (#83878) * Fix bug creating new policy on the fly * Adjust UI for input with vars but no streams * Revert "Fix bug creating new policy on the fly" This reverts commit 34f7014d6977cf73b792a64bcd9bf7c54b72cf42. * Add `compiled_input` field and compile input template, if any. Make compilation method names more generic (instead of only for streams). Add testts * Add compiled input to generated agent yaml * Don't return empty streams in agent yaml when there aren't any * Update missed assertion Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../package_policies_to_agent_inputs.test.ts | 42 +++++- .../package_policies_to_agent_inputs.ts | 33 +++-- .../fleet/common/types/models/agent_policy.ts | 2 +- .../plugins/fleet/common/types/models/epm.ts | 1 + .../common/types/models/package_policy.ts | 1 + .../package_policy_input_config.tsx | 24 ++-- .../components/package_policy_input_panel.tsx | 32 +++-- x-pack/plugins/fleet/server/mocks.ts | 2 +- .../routes/package_policy/handlers.test.ts | 2 +- .../fleet/server/saved_objects/index.ts | 1 + .../server/services/epm/agent/agent.test.ts | 14 +- .../fleet/server/services/epm/agent/agent.ts | 35 ++--- .../server/services/package_policy.test.ts | 128 +++++++++++++++++- .../fleet/server/services/package_policy.ts | 71 +++++++--- .../apps/endpoint/policy_details.ts | 1 - 15 files changed, 306 insertions(+), 83 deletions(-) diff --git a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts index f721afb63914..a370f92e97fe 100644 --- a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts +++ b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.test.ts @@ -100,7 +100,7 @@ describe('Fleet - storedPackagePoliciesToAgentInputs', () => { ).toEqual([]); }); - it('returns agent inputs', () => { + it('returns agent inputs with streams', () => { expect( storedPackagePoliciesToAgentInputs([ { @@ -143,6 +143,46 @@ describe('Fleet - storedPackagePoliciesToAgentInputs', () => { ]); }); + it('returns agent inputs without streams', () => { + expect( + storedPackagePoliciesToAgentInputs([ + { + ...mockPackagePolicy, + package: { + name: 'mock-package', + title: 'Mock package', + version: '0.0.0', + }, + inputs: [ + { + ...mockInput, + compiled_input: { + inputVar: 'input-value', + }, + streams: [], + }, + ], + }, + ]) + ).toEqual([ + { + id: 'some-uuid', + name: 'mock-package-policy', + revision: 1, + type: 'test-logs', + data_stream: { namespace: 'default' }, + use_output: 'default', + meta: { + package: { + name: 'mock-package', + version: '0.0.0', + }, + }, + inputVar: 'input-value', + }, + ]); + }); + it('returns agent inputs without disabled streams', () => { expect( storedPackagePoliciesToAgentInputs([ diff --git a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts index e74256ce732a..d780fb791aa8 100644 --- a/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts +++ b/x-pack/plugins/fleet/common/services/package_policies_to_agent_inputs.ts @@ -33,20 +33,25 @@ export const storedPackagePoliciesToAgentInputs = ( acc[key] = value; return acc; }, {} as { [k: string]: any }), - streams: input.streams - .filter((stream) => stream.enabled) - .map((stream) => { - const fullStream: FullAgentPolicyInputStream = { - id: stream.id, - data_stream: stream.data_stream, - ...stream.compiled_stream, - ...Object.entries(stream.config || {}).reduce((acc, [key, { value }]) => { - acc[key] = value; - return acc; - }, {} as { [k: string]: any }), - }; - return fullStream; - }), + ...(input.compiled_input || {}), + ...(input.streams.length + ? { + streams: input.streams + .filter((stream) => stream.enabled) + .map((stream) => { + const fullStream: FullAgentPolicyInputStream = { + id: stream.id, + data_stream: stream.data_stream, + ...stream.compiled_stream, + ...Object.entries(stream.config || {}).reduce((acc, [key, { value }]) => { + acc[key] = value; + return acc; + }, {} as { [k: string]: any }), + }; + return fullStream; + }), + } + : {}), }; if (packagePolicy.package) { diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index f43f65fb317f..75bb2998f2d9 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -49,7 +49,7 @@ export interface FullAgentPolicyInput { package?: Pick; [key: string]: unknown; }; - streams: FullAgentPolicyInputStream[]; + streams?: FullAgentPolicyInputStream[]; [key: string]: any; } diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 7a6f6232b2d4..53e507f6fb49 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -121,6 +121,7 @@ export interface RegistryInput { title: string; description?: string; vars?: RegistryVarsEntry[]; + template_path?: string; } export interface RegistryStream { diff --git a/x-pack/plugins/fleet/common/types/models/package_policy.ts b/x-pack/plugins/fleet/common/types/models/package_policy.ts index ae16899a4b6f..6da98a51ef1f 100644 --- a/x-pack/plugins/fleet/common/types/models/package_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/package_policy.ts @@ -42,6 +42,7 @@ export interface NewPackagePolicyInput { export interface PackagePolicyInput extends Omit { streams: PackagePolicyInputStream[]; + compiled_input?: any; } export interface NewPackagePolicy { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx index 75000ad7e1d3..9015cd09f78a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx @@ -27,6 +27,7 @@ const FlexItemWithMaxWidth = styled(EuiFlexItem)` `; export const PackagePolicyInputConfig: React.FunctionComponent<{ + hasInputStreams: boolean; packageInputVars?: RegistryVarsEntry[]; packagePolicyInput: NewPackagePolicyInput; updatePackagePolicyInput: (updatedInput: Partial) => void; @@ -34,6 +35,7 @@ export const PackagePolicyInputConfig: React.FunctionComponent<{ forceShowErrors?: boolean; }> = memo( ({ + hasInputStreams, packageInputVars, packagePolicyInput, updatePackagePolicyInput, @@ -82,15 +84,19 @@ export const PackagePolicyInputConfig: React.FunctionComponent<{ /> - - -

    - -

    -
    + {hasInputStreams ? ( + <> + + +

    + +

    +
    + + ) : null}
    diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx index 79ff0cc29850..8e242980ce80 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_panel.tsx @@ -3,7 +3,7 @@ * 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, { useState, Fragment, memo } from 'react'; +import React, { useState, Fragment, memo, useMemo } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -85,16 +85,23 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{ const errorCount = countValidationErrors(inputValidationResults); const hasErrors = forceShowErrors && errorCount; - const inputStreams = packageInputStreams - .map((packageInputStream) => { - return { - packageInputStream, - packagePolicyInputStream: packagePolicyInput.streams.find( - (stream) => stream.data_stream.dataset === packageInputStream.data_stream.dataset - ), - }; - }) - .filter((stream) => Boolean(stream.packagePolicyInputStream)); + const hasInputStreams = useMemo(() => packageInputStreams.length > 0, [ + packageInputStreams.length, + ]); + const inputStreams = useMemo( + () => + packageInputStreams + .map((packageInputStream) => { + return { + packageInputStream, + packagePolicyInputStream: packagePolicyInput.streams.find( + (stream) => stream.data_stream.dataset === packageInputStream.data_stream.dataset + ), + }; + }) + .filter((stream) => Boolean(stream.packagePolicyInputStream)), + [packageInputStreams, packagePolicyInput.streams] + ); return ( <> @@ -179,13 +186,14 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{ {isShowingStreams && packageInput.vars && packageInput.vars.length ? ( - + {hasInputStreams ? : } ) : null} diff --git a/x-pack/plugins/fleet/server/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index 91098c87c312..bc3e89ef6d3c 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -25,7 +25,7 @@ export const createAppContextStartContractMock = (): FleetAppContext => { export const createPackagePolicyServiceMock = () => { return { - assignPackageStream: jest.fn(), + compilePackagePolicyInputs: jest.fn(), buildPackagePolicyFromPackage: jest.fn(), bulkCreate: jest.fn(), create: jest.fn(), diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index f47b8499a1b6..fee74e39c833 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -22,7 +22,7 @@ jest.mock('../../services/package_policy', (): { } => { return { packagePolicyService: { - assignPackageStream: jest.fn((packageInfo, dataInputs) => Promise.resolve(dataInputs)), + compilePackagePolicyInputs: jest.fn((packageInfo, dataInputs) => Promise.resolve(dataInputs)), buildPackagePolicyFromPackage: jest.fn(), bulkCreate: jest.fn(), create: jest.fn((soClient, callCluster, newData) => diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 9d85a151efbb..201ca1c7a97b 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -242,6 +242,7 @@ const getSavedObjectTypes = ( enabled: { type: 'boolean' }, vars: { type: 'flattened' }, config: { type: 'flattened' }, + compiled_input: { type: 'flattened' }, streams: { type: 'nested', properties: { diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts index 54b40400bb4e..dba6f442d76e 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.test.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createStream } from './agent'; +import { compileTemplate } from './agent'; -describe('createStream', () => { +describe('compileTemplate', () => { it('should work', () => { const streamTemplate = ` input: log @@ -27,7 +27,7 @@ hidden_password: {{password}} password: { type: 'password', value: '' }, }; - const output = createStream(vars, streamTemplate); + const output = compileTemplate(vars, streamTemplate); expect(output).toEqual({ input: 'log', paths: ['/usr/local/var/log/nginx/access.log'], @@ -67,7 +67,7 @@ foo: bar password: { type: 'password', value: '' }, }; - const output = createStream(vars, streamTemplate); + const output = compileTemplate(vars, streamTemplate); expect(output).toEqual({ input: 'redis/metrics', metricsets: ['key'], @@ -114,7 +114,7 @@ hidden_password: {{password}} tags: { value: ['foo', 'bar', 'forwarded'] }, }; - const output = createStream(vars, streamTemplate); + const output = compileTemplate(vars, streamTemplate); expect(output).toEqual({ input: 'log', paths: ['/usr/local/var/log/nginx/access.log'], @@ -133,7 +133,7 @@ hidden_password: {{password}} tags: { value: ['foo', 'bar'] }, }; - const output = createStream(vars, streamTemplate); + const output = compileTemplate(vars, streamTemplate); expect(output).toEqual({ input: 'log', paths: ['/usr/local/var/log/nginx/access.log'], @@ -157,7 +157,7 @@ input: logs }, }; - const output = createStream(vars, streamTemplate); + const output = compileTemplate(vars, streamTemplate); expect(output).toEqual({ input: 'logs', }); diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts index eeadac6e168b..400a688722f9 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts @@ -10,27 +10,30 @@ import { PackagePolicyConfigRecord } from '../../../../common'; const handlebars = Handlebars.create(); -export function createStream(variables: PackagePolicyConfigRecord, streamTemplate: string) { - const { vars, yamlValues } = buildTemplateVariables(variables, streamTemplate); +export function compileTemplate(variables: PackagePolicyConfigRecord, templateStr: string) { + const { vars, yamlValues } = buildTemplateVariables(variables, templateStr); - const template = handlebars.compile(streamTemplate, { noEscape: true }); - let stream = template(vars); - stream = replaceRootLevelYamlVariables(yamlValues, stream); + const template = handlebars.compile(templateStr, { noEscape: true }); + let compiledTemplate = template(vars); + compiledTemplate = replaceRootLevelYamlVariables(yamlValues, compiledTemplate); - const yamlFromStream = safeLoad(stream, {}); + const yamlFromCompiledTemplate = safeLoad(compiledTemplate, {}); // Hack to keep empty string ('') values around in the end yaml because // `safeLoad` replaces empty strings with null - const patchedYamlFromStream = Object.entries(yamlFromStream).reduce((acc, [key, value]) => { - if (value === null && typeof vars[key] === 'string' && vars[key].trim() === '') { - acc[key] = ''; - } else { - acc[key] = value; - } - return acc; - }, {} as { [k: string]: any }); + const patchedYamlFromCompiledTemplate = Object.entries(yamlFromCompiledTemplate).reduce( + (acc, [key, value]) => { + if (value === null && typeof vars[key] === 'string' && vars[key].trim() === '') { + acc[key] = ''; + } else { + acc[key] = value; + } + return acc; + }, + {} as { [k: string]: any } + ); - return replaceVariablesInYaml(yamlValues, patchedYamlFromStream); + return replaceVariablesInYaml(yamlValues, patchedYamlFromCompiledTemplate); } function isValidKey(key: string) { @@ -54,7 +57,7 @@ function replaceVariablesInYaml(yamlVariables: { [k: string]: any }, yaml: any) return yaml; } -function buildTemplateVariables(variables: PackagePolicyConfigRecord, streamTemplate: string) { +function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateStr: string) { const yamlValues: { [k: string]: any } = {}; const vars = Object.entries(variables).reduce((acc, [key, recordEntry]) => { // support variables with . like key.patterns diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 6ae76c56436d..30a980ab07f7 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -25,7 +25,16 @@ paths: }, ]; } - return []; + return [ + { + buffer: Buffer.from(` +hosts: +{{#each hosts}} +- {{this}} +{{/each}} +`), + }, + ]; } jest.mock('./epm/packages/assets', () => { @@ -47,9 +56,9 @@ jest.mock('./epm/registry', () => { }); describe('Package policy service', () => { - describe('assignPackageStream', () => { + describe('compilePackagePolicyInputs', () => { it('should work with config variables from the stream', async () => { - const inputs = await packagePolicyService.assignPackageStream( + const inputs = await packagePolicyService.compilePackagePolicyInputs( ({ data_streams: [ { @@ -110,7 +119,7 @@ describe('Package policy service', () => { }); it('should work with config variables at the input level', async () => { - const inputs = await packagePolicyService.assignPackageStream( + const inputs = await packagePolicyService.compilePackagePolicyInputs( ({ data_streams: [ { @@ -169,6 +178,117 @@ describe('Package policy service', () => { }, ]); }); + + it('should work with an input with a template and no streams', async () => { + const inputs = await packagePolicyService.compilePackagePolicyInputs( + ({ + data_streams: [], + policy_templates: [ + { + inputs: [{ type: 'log', template_path: 'some_template_path.yml' }], + }, + ], + } as unknown) as PackageInfo, + [ + { + type: 'log', + enabled: true, + vars: { + hosts: { + value: ['localhost'], + }, + }, + streams: [], + }, + ] + ); + + expect(inputs).toEqual([ + { + type: 'log', + enabled: true, + vars: { + hosts: { + value: ['localhost'], + }, + }, + compiled_input: { + hosts: ['localhost'], + }, + streams: [], + }, + ]); + }); + + it('should work with an input with a template and streams', async () => { + const inputs = await packagePolicyService.compilePackagePolicyInputs( + ({ + data_streams: [ + { + dataset: 'package.dataset1', + type: 'logs', + streams: [{ input: 'log', template_path: 'some_template_path.yml' }], + }, + ], + policy_templates: [ + { + inputs: [{ type: 'log', template_path: 'some_template_path.yml' }], + }, + ], + } as unknown) as PackageInfo, + [ + { + type: 'log', + enabled: true, + vars: { + hosts: { + value: ['localhost'], + }, + paths: { + value: ['/var/log/set.log'], + }, + }, + streams: [ + { + id: 'datastream01', + data_stream: { dataset: 'package.dataset1', type: 'logs' }, + enabled: true, + }, + ], + }, + ] + ); + + expect(inputs).toEqual([ + { + type: 'log', + enabled: true, + vars: { + hosts: { + value: ['localhost'], + }, + paths: { + value: ['/var/log/set.log'], + }, + }, + compiled_input: { + hosts: ['localhost'], + }, + streams: [ + { + id: 'datastream01', + data_stream: { dataset: 'package.dataset1', type: 'logs' }, + enabled: true, + compiled_stream: { + metricset: ['dataset1'], + paths: ['/var/log/set.log'], + type: 'log', + }, + }, + ], + }, + ]); + }); }); describe('update', () => { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 0f78c97a6f2b..7b8952bdea2c 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -31,7 +31,7 @@ import { outputService } from './output'; import * as Registry from './epm/registry'; import { getPackageInfo, getInstallation, ensureInstalledPackage } from './epm/packages'; import { getAssetsData } from './epm/packages/assets'; -import { createStream } from './epm/agent/agent'; +import { compileTemplate } from './epm/agent/agent'; import { normalizeKuery } from './saved_object'; const SAVED_OBJECT_TYPE = PACKAGE_POLICY_SAVED_OBJECT_TYPE; @@ -92,7 +92,7 @@ class PackagePolicyService { } } - inputs = await this.assignPackageStream(pkgInfo, inputs); + inputs = await this.compilePackagePolicyInputs(pkgInfo, inputs); } const isoDate = new Date().toISOString(); @@ -285,7 +285,7 @@ class PackagePolicyService { pkgVersion: packagePolicy.package.version, }); - inputs = await this.assignPackageStream(pkgInfo, inputs); + inputs = await this.compilePackagePolicyInputs(pkgInfo, inputs); } await soClient.update( @@ -374,14 +374,20 @@ class PackagePolicyService { } } - public async assignPackageStream( + public async compilePackagePolicyInputs( pkgInfo: PackageInfo, inputs: PackagePolicyInput[] ): Promise { const registryPkgInfo = await Registry.fetchInfo(pkgInfo.name, pkgInfo.version); - const inputsPromises = inputs.map((input) => - _assignPackageStreamToInput(registryPkgInfo, pkgInfo, input) - ); + const inputsPromises = inputs.map(async (input) => { + const compiledInput = await _compilePackagePolicyInput(registryPkgInfo, pkgInfo, input); + const compiledStreams = await _compilePackageStreams(registryPkgInfo, pkgInfo, input); + return { + ...input, + compiled_input: compiledInput, + streams: compiledStreams, + }; + }); return Promise.all(inputsPromises); } @@ -396,20 +402,53 @@ function assignStreamIdToInput(packagePolicyId: string, input: NewPackagePolicyI }; } -async function _assignPackageStreamToInput( +async function _compilePackagePolicyInput( + registryPkgInfo: RegistryPackage, + pkgInfo: PackageInfo, + input: PackagePolicyInput +) { + if (!input.enabled || !pkgInfo.policy_templates?.[0].inputs) { + return undefined; + } + + const packageInputs = pkgInfo.policy_templates[0].inputs; + const packageInput = packageInputs.find((pkgInput) => pkgInput.type === input.type); + if (!packageInput) { + throw new Error(`Input template not found, unable to find input type ${input.type}`); + } + + if (!packageInput.template_path) { + return undefined; + } + + const [pkgInputTemplate] = await getAssetsData(registryPkgInfo, (path: string) => + path.endsWith(`/agent/input/${packageInput.template_path!}`) + ); + + if (!pkgInputTemplate || !pkgInputTemplate.buffer) { + throw new Error(`Unable to load input template at /agent/input/${packageInput.template_path!}`); + } + + return compileTemplate( + // Populate template variables from input vars + Object.assign({}, input.vars), + pkgInputTemplate.buffer.toString() + ); +} + +async function _compilePackageStreams( registryPkgInfo: RegistryPackage, pkgInfo: PackageInfo, input: PackagePolicyInput ) { const streamsPromises = input.streams.map((stream) => - _assignPackageStreamToStream(registryPkgInfo, pkgInfo, input, stream) + _compilePackageStream(registryPkgInfo, pkgInfo, input, stream) ); - const streams = await Promise.all(streamsPromises); - return { ...input, streams }; + return await Promise.all(streamsPromises); } -async function _assignPackageStreamToStream( +async function _compilePackageStream( registryPkgInfo: RegistryPackage, pkgInfo: PackageInfo, input: PackagePolicyInput, @@ -442,22 +481,22 @@ async function _assignPackageStreamToStream( throw new Error(`Stream template path not found for dataset ${datasetPath}`); } - const [pkgStream] = await getAssetsData( + const [pkgStreamTemplate] = await getAssetsData( registryPkgInfo, (path: string) => path.endsWith(streamFromPkg.template_path), datasetPath ); - if (!pkgStream || !pkgStream.buffer) { + if (!pkgStreamTemplate || !pkgStreamTemplate.buffer) { throw new Error( `Unable to load stream template ${streamFromPkg.template_path} for dataset ${datasetPath}` ); } - const yaml = createStream( + const yaml = compileTemplate( // Populate template variables from input vars and stream vars Object.assign({}, input.vars, stream.vars), - pkgStream.buffer.toString() + pkgStreamTemplate.buffer.toString() ); stream.compiled_stream = yaml; diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index b3c130ea1e5d..46085b0db306 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -249,7 +249,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, }, }, - streams: [], type: 'endpoint', use_output: 'default', }, From 497511272308d2aae3f81e26121588aa73dfdd24 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 25 Nov 2020 12:12:28 -0700 Subject: [PATCH 73/89] [cli/dev] log a warning when --no-base-path is used with --dev (#84354) Co-authored-by: spalger --- src/cli/cluster/cluster_manager.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index 7a14f617b5d5..b0f7cded938d 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -73,6 +73,19 @@ export class ClusterManager { this.inReplMode = !!opts.repl; this.basePathProxy = basePathProxy; + if (!this.basePathProxy) { + this.log.warn( + '====================================================================================================' + ); + this.log.warn( + 'no-base-path', + 'Running Kibana in dev mode with --no-base-path disables several useful features and is not recommended' + ); + this.log.warn( + '====================================================================================================' + ); + } + // run @kbn/optimizer and write it's state to kbnOptimizerReady$ if (opts.disableOptimizer) { this.kbnOptimizerReady$.next(true); From 5fda30001f69d536bfa7f0f84ecd5f6e7df1dcc8 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Wed, 25 Nov 2020 14:47:11 -0500 Subject: [PATCH 74/89] [Security Solution][Resolver] Add support for predefined schemas for endpoint and winlogbeat (#84103) * Refactoring entity route to return schema * Refactoring frontend middleware to pick off id field from entity route * Refactoring schema and adding name and comments * Adding name to schema mocks * Fixing type issue --- .../common/endpoint/types/index.ts | 39 ++++- .../mocks/no_ancestors_two_children.ts | 13 +- ..._children_in_index_called_awesome_index.ts | 13 +- ...ith_related_events_and_cursor_on_origin.ts | 13 +- ..._children_with_related_events_on_origin.ts | 13 +- .../one_node_with_paginated_related_events.ts | 13 +- .../store/middleware/resolver_tree_fetcher.ts | 2 +- .../server/endpoint/routes/resolver/entity.ts | 147 ++++++++++++------ .../resolver/tree/queries/descendants.ts | 8 +- .../routes/resolver/tree/queries/lifecycle.ts | 8 +- .../routes/resolver/tree/queries/stats.ts | 8 +- .../routes/resolver/tree/utils/fetch.test.ts | 9 +- .../routes/resolver/tree/utils/fetch.ts | 25 +-- .../routes/resolver/tree/utils/index.ts | 27 +--- .../apis/resolver/common.ts | 25 +-- .../apis/resolver/entity.ts | 9 +- .../apis/resolver/tree.ts | 12 +- 17 files changed, 259 insertions(+), 125 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index cd5c60e2698c..d6be83d7cbbe 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -870,10 +870,47 @@ export interface SafeLegacyEndpointEvent { }>; } +/** + * The fields to use to identify nodes within a resolver tree. + */ +export interface ResolverSchema { + /** + * the ancestry field should be set to a field that contains an order array representing + * the ancestors of a node. + */ + ancestry?: string; + /** + * id represents the field to use as the unique ID for a node. + */ + id: string; + /** + * field to use for the name of the node + */ + name?: string; + /** + * parent represents the field that is the edge between two nodes. + */ + parent: string; +} + /** * The response body for the resolver '/entity' index API */ -export type ResolverEntityIndex = Array<{ entity_id: string }>; +export type ResolverEntityIndex = Array<{ + /** + * A name for the schema that is being used (e.g. endpoint, winlogbeat, etc) + */ + name: string; + /** + * The schema to pass to the /tree api and other backend requests, based on the contents of the document found using + * the _id + */ + schema: ResolverSchema; + /** + * Unique ID value for the requested document using the `_id` field passed to the /entity route + */ + id: string; +}>; /** * Takes a @kbn/config-schema 'schema' type and returns a type that represents valid inputs. diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts index 09625e5726b1..472fdc79d1f0 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts @@ -99,7 +99,18 @@ export function noAncestorsTwoChildren(): { dataAccessLayer: DataAccessLayer; me * Get entities matching a document. */ entities(): Promise { - return Promise.resolve([{ entity_id: metadata.entityIDs.origin }]); + return Promise.resolve([ + { + name: 'endpoint', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + ancestry: 'process.Ext.ancestry', + name: 'process.name', + }, + id: metadata.entityIDs.origin, + }, + ]); }, }, }; diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts index 3bbe4bcf5106..b085738d3fd2 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts @@ -115,7 +115,18 @@ export function noAncestorsTwoChildenInIndexCalledAwesomeIndex(): { entities({ indices }): Promise { // Only return values if the `indices` array contains exactly `'awesome_index'` if (indices.length === 1 && indices[0] === 'awesome_index') { - return Promise.resolve([{ entity_id: metadata.entityIDs.origin }]); + return Promise.resolve([ + { + name: 'endpoint', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + ancestry: 'process.Ext.ancestry', + name: 'process.name', + }, + id: metadata.entityIDs.origin, + }, + ]); } return Promise.resolve([]); }, diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_and_cursor_on_origin.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_and_cursor_on_origin.ts index 7682165ac5e9..43704db358d7 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_and_cursor_on_origin.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_and_cursor_on_origin.ts @@ -140,7 +140,18 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOriginWithOneAfterCurso * Get entities matching a document. */ async entities(): Promise { - return [{ entity_id: metadata.entityIDs.origin }]; + return [ + { + name: 'endpoint', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + ancestry: 'process.Ext.ancestry', + name: 'process.name', + }, + id: metadata.entityIDs.origin, + }, + ]; }, }, }; diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts index 837d824db874..c4d538d2eed9 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts @@ -112,7 +112,18 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): { * Get entities matching a document. */ async entities(): Promise { - return [{ entity_id: metadata.entityIDs.origin }]; + return [ + { + name: 'endpoint', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + ancestry: 'process.Ext.ancestry', + name: 'process.name', + }, + id: metadata.entityIDs.origin, + }, + ]; }, }, }; diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts index 01477ff16868..7849776ed137 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts @@ -103,7 +103,18 @@ export function oneNodeWithPaginatedEvents(): { * Get entities matching a document. */ async entities(): Promise { - return [{ entity_id: metadata.entityIDs.origin }]; + return [ + { + name: 'endpoint', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + ancestry: 'process.Ext.ancestry', + name: 'process.name', + }, + id: metadata.entityIDs.origin, + }, + ]; }, }, }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts index ef4ca2380ebf..aecdd6b92a46 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts @@ -56,7 +56,7 @@ export function ResolverTreeFetcher( }); return; } - const entityIDToFetch = matchingEntities[0].entity_id; + const entityIDToFetch = matchingEntities[0].id; result = await dataAccessLayer.resolverTree( entityIDToFetch, lastRequestAbortController.signal diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts index 510bb6c54555..c731692e6fb8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts @@ -3,10 +3,70 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { RequestHandler } from 'kibana/server'; +import _ from 'lodash'; +import { RequestHandler, SearchResponse } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; +import { ApiResponse } from '@elastic/elasticsearch'; import { validateEntities } from '../../../../common/endpoint/schema/resolver'; -import { ResolverEntityIndex } from '../../../../common/endpoint/types'; +import { ResolverEntityIndex, ResolverSchema } from '../../../../common/endpoint/types'; + +interface SupportedSchema { + /** + * A name for the schema being used + */ + name: string; + + /** + * A constraint to search for in the documented returned by Elasticsearch + */ + constraint: { field: string; value: string }; + + /** + * Schema to return to the frontend so that it can be passed in to call to the /tree API + */ + schema: ResolverSchema; +} + +/** + * This structure defines the preset supported schemas for a resolver graph. We'll probably want convert this + * implementation to something similar to how row renderers is implemented. + */ +const supportedSchemas: SupportedSchema[] = [ + { + name: 'endpoint', + constraint: { + field: 'agent.type', + value: 'endpoint', + }, + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + ancestry: 'process.Ext.ancestry', + name: 'process.name', + }, + }, + { + name: 'winlogbeat', + constraint: { + field: 'agent.type', + value: 'winlogbeat', + }, + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + name: 'process.name', + }, + }, +]; + +function getFieldAsString(doc: unknown, field: string): string | undefined { + const value = _.get(doc, field); + if (value === undefined) { + return undefined; + } + + return String(value); +} /** * This is used to get an 'entity_id' which is an internal-to-Resolver concept, from an `_id`, which @@ -18,61 +78,46 @@ export function handleEntities(): RequestHandler + > = await context.core.elasticsearch.client.asCurrentUser.search({ + ignore_unavailable: true, + index: indices, + body: { + // only return 1 match at most + size: 1, + query: { + bool: { + filter: [ { - _source: { - process?: { - entity_id?: string; - }; - }; - } - ]; - }; - } - - const queryResponse: ExpectedQueryResponse = await context.core.elasticsearch.legacy.client.callAsCurrentUser( - 'search', - { - ignoreUnavailable: true, - index: indices, - body: { - // only return process.entity_id - _source: 'process.entity_id', - // only return 1 match at most - size: 1, - query: { - bool: { - filter: [ - { - // only return documents with the matching _id - ids: { - values: _id, - }, + // only return documents with the matching _id + ids: { + values: _id, }, - ], - }, + }, + ], }, }, - } - ); + }, + }); const responseBody: ResolverEntityIndex = []; - for (const hit of queryResponse.hits.hits) { - // check that the field is defined and that is not an empty string - if (hit._source.process?.entity_id) { - responseBody.push({ - entity_id: hit._source.process.entity_id, - }); + for (const hit of queryResponse.body.hits.hits) { + for (const supportedSchema of supportedSchemas) { + const fieldValue = getFieldAsString(hit._source, supportedSchema.constraint.field); + const id = getFieldAsString(hit._source, supportedSchema.schema.id); + // check that the constraint and id fields are defined and that the id field is not an empty string + if ( + fieldValue?.toLowerCase() === supportedSchema.constraint.value.toLowerCase() && + id !== undefined && + id !== '' + ) { + responseBody.push({ + name: supportedSchema.name, + schema: supportedSchema.schema, + id, + }); + } } } return response.ok({ body: responseBody }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts index 405429cc2419..3baf3a866752 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts @@ -6,12 +6,12 @@ import { SearchResponse } from 'elasticsearch'; import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; -import { FieldsObject } from '../../../../../../common/endpoint/types'; +import { FieldsObject, ResolverSchema } from '../../../../../../common/endpoint/types'; import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; -import { NodeID, Schema, Timerange, docValueFields } from '../utils/index'; +import { NodeID, Timerange, docValueFields } from '../utils/index'; interface DescendantsParams { - schema: Schema; + schema: ResolverSchema; indexPatterns: string | string[]; timerange: Timerange; } @@ -20,7 +20,7 @@ interface DescendantsParams { * Builds a query for retrieving descendants of a node. */ export class DescendantsQuery { - private readonly schema: Schema; + private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; private readonly timerange: Timerange; private readonly docValueFields: JsonValue[]; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts index 606a4538ba88..5253806be66b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts @@ -6,12 +6,12 @@ import { SearchResponse } from 'elasticsearch'; import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; -import { FieldsObject } from '../../../../../../common/endpoint/types'; +import { FieldsObject, ResolverSchema } from '../../../../../../common/endpoint/types'; import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; -import { NodeID, Schema, Timerange, docValueFields } from '../utils/index'; +import { NodeID, Timerange, docValueFields } from '../utils/index'; interface LifecycleParams { - schema: Schema; + schema: ResolverSchema; indexPatterns: string | string[]; timerange: Timerange; } @@ -20,7 +20,7 @@ interface LifecycleParams { * Builds a query for retrieving descendants of a node. */ export class LifecycleQuery { - private readonly schema: Schema; + private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; private readonly timerange: Timerange; private readonly docValueFields: JsonValue[]; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts index 33dcdce8987f..117cc3647dd0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts @@ -7,8 +7,8 @@ import { SearchResponse } from 'elasticsearch'; import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; import { JsonObject } from '../../../../../../../../../src/plugins/kibana_utils/common'; -import { EventStats } from '../../../../../../common/endpoint/types'; -import { NodeID, Schema, Timerange } from '../utils/index'; +import { EventStats, ResolverSchema } from '../../../../../../common/endpoint/types'; +import { NodeID, Timerange } from '../utils/index'; interface AggBucket { key: string; @@ -26,7 +26,7 @@ interface CategoriesAgg extends AggBucket { } interface StatsParams { - schema: Schema; + schema: ResolverSchema; indexPatterns: string | string[]; timerange: Timerange; } @@ -35,7 +35,7 @@ interface StatsParams { * Builds a query for retrieving descendants of a node. */ export class StatsQuery { - private readonly schema: Schema; + private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; private readonly timerange: Timerange; constructor({ schema, indexPatterns, timerange }: StatsParams) { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts index 8105f1125d01..d5e0af9dea23 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts @@ -18,14 +18,17 @@ import { DescendantsQuery } from '../queries/descendants'; import { StatsQuery } from '../queries/stats'; import { IScopedClusterClient } from 'src/core/server'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; -import { FieldsObject, ResolverNode } from '../../../../../../common/endpoint/types'; -import { Schema } from './index'; +import { + FieldsObject, + ResolverNode, + ResolverSchema, +} from '../../../../../../common/endpoint/types'; jest.mock('../queries/descendants'); jest.mock('../queries/lifecycle'); jest.mock('../queries/stats'); -function formatResponse(results: FieldsObject[], schema: Schema): ResolverNode[] { +function formatResponse(results: FieldsObject[], schema: ResolverSchema): ResolverNode[] { return results.map((node) => { return { id: getIDField(node, schema) ?? '', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts index eaecad6c4797..356357082d6e 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts @@ -8,9 +8,14 @@ import { firstNonNullValue, values, } from '../../../../../../common/endpoint/models/ecs_safety_helpers'; -import { ECSField, ResolverNode, FieldsObject } from '../../../../../../common/endpoint/types'; +import { + ECSField, + ResolverNode, + FieldsObject, + ResolverSchema, +} from '../../../../../../common/endpoint/types'; import { DescendantsQuery } from '../queries/descendants'; -import { Schema, NodeID } from './index'; +import { NodeID } from './index'; import { LifecycleQuery } from '../queries/lifecycle'; import { StatsQuery } from '../queries/stats'; @@ -26,7 +31,7 @@ export interface TreeOptions { from: string; to: string; }; - schema: Schema; + schema: ResolverSchema; nodes: NodeID[]; indexPatterns: string[]; } @@ -98,7 +103,7 @@ export class Fetcher { private static getNextAncestorsToFind( results: FieldsObject[], - schema: Schema, + schema: ResolverSchema, levelsLeft: number ): NodeID[] { const nodesByID = results.reduce((accMap: Map, result: FieldsObject) => { @@ -216,7 +221,7 @@ export class Fetcher { export function getLeafNodes( results: FieldsObject[], nodes: Array, - schema: Schema + schema: ResolverSchema ): NodeID[] { let largestAncestryArray = 0; const nodesToQueryNext: Map> = new Map(); @@ -269,7 +274,7 @@ export function getLeafNodes( * @param obj the doc value fields retrieved from a document returned by Elasticsearch * @param schema the schema used for identifying connections between documents */ -export function getIDField(obj: FieldsObject, schema: Schema): NodeID | undefined { +export function getIDField(obj: FieldsObject, schema: ResolverSchema): NodeID | undefined { const id: ECSField = obj[schema.id]; return firstNonNullValue(id); } @@ -281,7 +286,7 @@ export function getIDField(obj: FieldsObject, schema: Schema): NodeID | undefine * @param obj the doc value fields retrieved from a document returned by Elasticsearch * @param schema the schema used for identifying connections between documents */ -export function getNameField(obj: FieldsObject, schema: Schema): string | undefined { +export function getNameField(obj: FieldsObject, schema: ResolverSchema): string | undefined { if (!schema.name) { return undefined; } @@ -297,12 +302,12 @@ export function getNameField(obj: FieldsObject, schema: Schema): string | undefi * @param obj the doc value fields retrieved from a document returned by Elasticsearch * @param schema the schema used for identifying connections between documents */ -export function getParentField(obj: FieldsObject, schema: Schema): NodeID | undefined { +export function getParentField(obj: FieldsObject, schema: ResolverSchema): NodeID | undefined { const parent: ECSField = obj[schema.parent]; return firstNonNullValue(parent); } -function getAncestryField(obj: FieldsObject, schema: Schema): NodeID[] | undefined { +function getAncestryField(obj: FieldsObject, schema: ResolverSchema): NodeID[] | undefined { if (!schema.ancestry) { return undefined; } @@ -324,7 +329,7 @@ function getAncestryField(obj: FieldsObject, schema: Schema): NodeID[] | undefin * @param obj the doc value fields retrieved from a document returned by Elasticsearch * @param schema the schema used for identifying connections between documents */ -export function getAncestryAsArray(obj: FieldsObject, schema: Schema): NodeID[] { +export function getAncestryAsArray(obj: FieldsObject, schema: ResolverSchema): NodeID[] { const ancestry = getAncestryField(obj, schema); if (!ancestry || ancestry.length <= 0) { const parentField = getParentField(obj, schema); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts index 21a49e268310..be08b4390a69 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ResolverSchema } from '../../../../../../common/endpoint/types'; + /** * Represents a time range filter */ @@ -17,29 +19,6 @@ export interface Timerange { */ export type NodeID = string | number; -/** - * The fields to use to identify nodes within a resolver tree. - */ -export interface Schema { - /** - * the ancestry field should be set to a field that contains an order array representing - * the ancestors of a node. - */ - ancestry?: string; - /** - * id represents the field to use as the unique ID for a node. - */ - id: string; - /** - * field to use for the name of the node - */ - name?: string; - /** - * parent represents the field that is the edge between two nodes. - */ - parent: string; -} - /** * Returns the doc value fields filter to use in queries to limit the number of fields returned in the * query response. @@ -49,7 +28,7 @@ export interface Schema { * @param schema is the node schema information describing how relationships are formed between nodes * in the resolver graph. */ -export function docValueFields(schema: Schema): Array<{ field: string }> { +export function docValueFields(schema: ResolverSchema): Array<{ field: string }> { const filter = [{ field: '@timestamp' }, { field: schema.id }, { field: schema.parent }]; if (schema.ancestry) { filter.push({ field: schema.ancestry }); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts index b4e98d7d4b95..3cc833c6a247 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/common.ts @@ -6,16 +6,14 @@ import _ from 'lodash'; import expect from '@kbn/expect'; import { firstNonNullValue } from '../../../../plugins/security_solution/common/endpoint/models/ecs_safety_helpers'; -import { - NodeID, - Schema, -} from '../../../../plugins/security_solution/server/endpoint/routes/resolver/tree/utils'; +import { NodeID } from '../../../../plugins/security_solution/server/endpoint/routes/resolver/tree/utils'; import { SafeResolverChildNode, SafeResolverLifecycleNode, SafeResolverEvent, ResolverNodeStats, ResolverNode, + ResolverSchema, } from '../../../../plugins/security_solution/common/endpoint/types'; import { parentEntityIDSafeVersion, @@ -41,7 +39,7 @@ const createLevels = ({ descendantsByParent: Map>; levels: Array>; currentNodes: Map | undefined; - schema: Schema; + schema: ResolverSchema; }): Array> => { if (!currentNodes || currentNodes.size === 0) { return levels; @@ -98,7 +96,7 @@ export interface APIResponse { * @param node a resolver node * @param schema the schema that was used to retrieve this resolver node */ -export const getID = (node: ResolverNode | undefined, schema: Schema): NodeID => { +export const getID = (node: ResolverNode | undefined, schema: ResolverSchema): NodeID => { const id = firstNonNullValue(node?.data[schema.id]); if (!id) { throw new Error(`Unable to find id ${schema.id} in node: ${JSON.stringify(node)}`); @@ -106,7 +104,10 @@ export const getID = (node: ResolverNode | undefined, schema: Schema): NodeID => return id; }; -const getParentInternal = (node: ResolverNode | undefined, schema: Schema): NodeID | undefined => { +const getParentInternal = ( + node: ResolverNode | undefined, + schema: ResolverSchema +): NodeID | undefined => { if (node) { return firstNonNullValue(node?.data[schema.parent]); } @@ -119,7 +120,7 @@ const getParentInternal = (node: ResolverNode | undefined, schema: Schema): Node * @param node a resolver node * @param schema the schema that was used to retrieve this resolver node */ -export const getParent = (node: ResolverNode | undefined, schema: Schema): NodeID => { +export const getParent = (node: ResolverNode | undefined, schema: ResolverSchema): NodeID => { const parent = getParentInternal(node, schema); if (!parent) { throw new Error(`Unable to find parent ${schema.parent} in node: ${JSON.stringify(node)}`); @@ -138,7 +139,7 @@ export const getParent = (node: ResolverNode | undefined, schema: Schema): NodeI const createTreeFromResponse = ( treeExpectations: TreeExpectation[], nodes: ResolverNode[], - schema: Schema + schema: ResolverSchema ) => { const nodesByID = new Map(); const nodesByParent = new Map>(); @@ -206,7 +207,7 @@ const verifyAncestry = ({ genTree, }: { responseTrees: APIResponse; - schema: Schema; + schema: ResolverSchema; genTree: Tree; }) => { const allGenNodes = new Map([ @@ -277,7 +278,7 @@ const verifyChildren = ({ genTree, }: { responseTrees: APIResponse; - schema: Schema; + schema: ResolverSchema; genTree: Tree; }) => { const allGenNodes = new Map([ @@ -358,7 +359,7 @@ export const verifyTree = ({ }: { expectations: TreeExpectation[]; response: ResolverNode[]; - schema: Schema; + schema: ResolverSchema; genTree: Tree; relatedEventsCategories?: RelatedEventInfo[]; }) => { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts index 7fbba4e04798..2607b934e7df 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts @@ -29,8 +29,15 @@ export default function ({ getService }: FtrProviderContext) { ); expect(body).eql([ { + name: 'endpoint', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + ancestry: 'process.Ext.ancestry', + name: 'process.name', + }, // this value is from the es archive - entity_id: + id: 'MTIwNWY1NWQtODRkYS00MzkxLWIyNWQtYTNkNGJmNDBmY2E1LTc1NTItMTMyNDM1NDY1MTQuNjI0MjgxMDA=', }, ]); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts index 646a666629ac..7a1210c6b762 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts @@ -5,8 +5,10 @@ */ import expect from '@kbn/expect'; import { getNameField } from '../../../../plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch'; -import { Schema } from '../../../../plugins/security_solution/server/endpoint/routes/resolver/tree/utils'; -import { ResolverNode } from '../../../../plugins/security_solution/common/endpoint/types'; +import { + ResolverNode, + ResolverSchema, +} from '../../../../plugins/security_solution/common/endpoint/types'; import { parentEntityIDSafeVersion, timestampSafeVersion, @@ -44,18 +46,18 @@ export default function ({ getService }: FtrProviderContext) { ancestryArraySize: 2, }; - const schemaWithAncestry: Schema = { + const schemaWithAncestry: ResolverSchema = { ancestry: 'process.Ext.ancestry', id: 'process.entity_id', parent: 'process.parent.entity_id', }; - const schemaWithoutAncestry: Schema = { + const schemaWithoutAncestry: ResolverSchema = { id: 'process.entity_id', parent: 'process.parent.entity_id', }; - const schemaWithName: Schema = { + const schemaWithName: ResolverSchema = { id: 'process.entity_id', parent: 'process.parent.entity_id', name: 'process.name', From dfa9c75021f1872b31f770c4009cc2e0d556dbfe Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 25 Nov 2020 15:15:46 -0500 Subject: [PATCH 75/89] Fix issues with show_license_expiration (#84361) --- .../cluster/overview/elasticsearch_panel.js | 71 +++++++++++-------- .../alerts/license_expiration_alert.test.ts | 28 ++++++++ .../server/alerts/license_expiration_alert.ts | 9 ++- 3 files changed, 77 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js b/x-pack/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js index 7e85d62c4bbd..ded309ce64e2 100644 --- a/x-pack/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js +++ b/x-pack/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js @@ -230,6 +230,46 @@ export function ElasticsearchPanel(props) { return null; }; + const showLicense = () => { + if (!props.showLicenseExpiration) { + return null; + } + return ( + + + + + + + + + {capitalize(props.license.type)} + + + + + {props.license.expiry_date_in_millis === undefined ? ( + '' + ) : ( + + )} + + + + + + ); + }; + const statusColorMap = { green: 'success', yellow: 'warning', @@ -325,36 +365,7 @@ export function ElasticsearchPanel(props) { {formatNumber(get(nodes, 'jvm.max_uptime_in_millis'), 'time_since')} {showMlJobs()} - - - - - - - - {capitalize(props.license.type)} - - - - - {props.license.expiry_date_in_millis === undefined ? ( - '' - ) : ( - - )} - - - - + {showLicense()} diff --git a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts index 74c300d97189..b82b4c235acb 100644 --- a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.test.ts @@ -76,6 +76,7 @@ describe('LicenseExpirationAlert', () => { const monitoringCluster = null; const config = { ui: { + show_license_expiration: true, ccs: { enabled: true }, container: { elasticsearch: { enabled: false } }, metricbeat: { index: 'metricbeat-*' }, @@ -282,5 +283,32 @@ describe('LicenseExpirationAlert', () => { state: 'resolved', }); }); + + it('should not fire actions if we are not showing license expiration', async () => { + const alert = new LicenseExpirationAlert(); + const customConfig = { + ...config, + ui: { + ...config.ui, + show_license_expiration: false, + }, + }; + alert.initializeAlertType( + getUiSettingsService as any, + monitoringCluster as any, + getLogger as any, + customConfig as any, + kibanaUrl, + false + ); + const type = alert.getAlertType(); + await type.executor({ + ...executorOptions, + // @ts-ignore + params: alert.defaultParams, + } as any); + expect(replaceState).not.toHaveBeenCalledWith({}); + expect(scheduleActions).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts index 00846e9cf759..9692d95bfc6f 100644 --- a/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/license_expiration_alert.ts @@ -18,7 +18,7 @@ import { LegacyAlert, CommonAlertParams, } from '../../common/types/alerts'; -import { AlertInstance } from '../../../alerts/server'; +import { AlertExecutorOptions, AlertInstance } from '../../../alerts/server'; import { INDEX_ALERTS, ALERT_LICENSE_EXPIRATION, @@ -64,6 +64,13 @@ export class LicenseExpirationAlert extends BaseAlert { AlertingDefaults.ALERT_TYPE.context.actionPlain, ]; + protected async execute(options: AlertExecutorOptions): Promise { + if (!this.config.ui.show_license_expiration) { + return; + } + return await super.execute(options); + } + protected async fetchData( params: CommonAlertParams, callCluster: any, From 71f77862e7a6fa00c248c854a7814a2331d22841 Mon Sep 17 00:00:00 2001 From: Dan Panzarella Date: Wed, 25 Nov 2020 15:29:23 -0500 Subject: [PATCH 76/89] [Security Solution] Add Endpoint policy feature checks (#83972) --- .../common/endpoint/models/policy_config.ts | 5 + .../common/license/license.ts | 33 +++--- .../common/license/policy_config.test.ts | 110 ++++++++++++++++++ .../common/license/policy_config.ts | 66 +++++++++++ .../policy/store/policy_details/middleware.ts | 7 +- 5 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/license/policy_config.test.ts create mode 100644 x-pack/plugins/security_solution/common/license/policy_config.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts index 890def5b63d4..22037c021701 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts @@ -68,3 +68,8 @@ export const factory = (): PolicyConfig => { }, }; }; + +/** + * Reflects what string the Endpoint will use when message field is default/empty + */ +export const DefaultMalwareMessage = 'Elastic Security { action } { filename }'; diff --git a/x-pack/plugins/security_solution/common/license/license.ts b/x-pack/plugins/security_solution/common/license/license.ts index 96c1a14ceb1f..2d424ab9c960 100644 --- a/x-pack/plugins/security_solution/common/license/license.ts +++ b/x-pack/plugins/security_solution/common/license/license.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { Observable, Subscription } from 'rxjs'; -import { ILicense } from '../../../licensing/common/types'; +import { ILicense, LicenseType } from '../../../licensing/common/types'; // Generic license service class that works with the license observable // Both server and client plugins instancates a singleton version of this class @@ -36,25 +36,20 @@ export class LicenseService { return this.observable; } - public isGoldPlus() { - return ( - this.licenseInformation?.isAvailable && - this.licenseInformation?.isActive && - this.licenseInformation?.hasAtLeast('gold') - ); + public isAtLeast(level: LicenseType): boolean { + return isAtLeast(this.licenseInformation, level); } - public isPlatinumPlus() { - return ( - this.licenseInformation?.isAvailable && - this.licenseInformation?.isActive && - this.licenseInformation?.hasAtLeast('platinum') - ); + public isGoldPlus(): boolean { + return this.isAtLeast('gold'); } - public isEnterprise() { - return ( - this.licenseInformation?.isAvailable && - this.licenseInformation?.isActive && - this.licenseInformation?.hasAtLeast('enterprise') - ); + public isPlatinumPlus(): boolean { + return this.isAtLeast('platinum'); + } + public isEnterprise(): boolean { + return this.isAtLeast('enterprise'); } } + +export const isAtLeast = (license: ILicense | null, level: LicenseType): boolean => { + return license !== null && license.isAvailable && license.isActive && license.hasAtLeast(level); +}; diff --git a/x-pack/plugins/security_solution/common/license/policy_config.test.ts b/x-pack/plugins/security_solution/common/license/policy_config.test.ts new file mode 100644 index 000000000000..6923bf00055f --- /dev/null +++ b/x-pack/plugins/security_solution/common/license/policy_config.test.ts @@ -0,0 +1,110 @@ +/* + * 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 { + isEndpointPolicyValidForLicense, + unsetPolicyFeaturesAboveLicenseLevel, +} from './policy_config'; +import { DefaultMalwareMessage, factory } from '../endpoint/models/policy_config'; +import { licenseMock } from '../../../licensing/common/licensing.mock'; + +describe('policy_config and licenses', () => { + const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } }); + const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } }); + const Basic = licenseMock.createLicense({ license: { type: 'basic', mode: 'basic' } }); + + describe('isEndpointPolicyValidForLicense', () => { + it('allows malware notification to be disabled with a Platinum license', () => { + const policy = factory(); + policy.windows.popup.malware.enabled = false; // make policy change + const valid = isEndpointPolicyValidForLicense(policy, Platinum); + expect(valid).toBeTruthy(); + }); + it('blocks windows malware notification changes below Platinum licenses', () => { + const policy = factory(); + policy.windows.popup.malware.enabled = false; // make policy change + let valid = isEndpointPolicyValidForLicense(policy, Gold); + expect(valid).toBeFalsy(); + + valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeFalsy(); + }); + + it('blocks mac malware notification changes below Platinum licenses', () => { + const policy = factory(); + policy.mac.popup.malware.enabled = false; // make policy change + let valid = isEndpointPolicyValidForLicense(policy, Gold); + expect(valid).toBeFalsy(); + + valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeFalsy(); + }); + + it('allows malware notification message changes with a Platinum license', () => { + const policy = factory(); + policy.windows.popup.malware.message = 'BOOM'; // make policy change + const valid = isEndpointPolicyValidForLicense(policy, Platinum); + expect(valid).toBeTruthy(); + }); + it('blocks windows malware notification message changes below Platinum licenses', () => { + const policy = factory(); + policy.windows.popup.malware.message = 'BOOM'; // make policy change + let valid = isEndpointPolicyValidForLicense(policy, Gold); + expect(valid).toBeFalsy(); + + valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeFalsy(); + }); + it('blocks mac malware notification message changes below Platinum licenses', () => { + const policy = factory(); + policy.mac.popup.malware.message = 'BOOM'; // make policy change + let valid = isEndpointPolicyValidForLicense(policy, Gold); + expect(valid).toBeFalsy(); + + valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeFalsy(); + }); + + it('allows default policyConfig with Basic', () => { + const policy = factory(); + const valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeTruthy(); + }); + }); + + describe('unsetPolicyFeaturesAboveLicenseLevel', () => { + it('does not change any fields with a Platinum license', () => { + const policy = factory(); + const popupMessage = 'WOOP WOOP'; + policy.windows.popup.malware.message = popupMessage; + policy.mac.popup.malware.message = popupMessage; + policy.windows.popup.malware.enabled = false; + + const retPolicy = unsetPolicyFeaturesAboveLicenseLevel(policy, Platinum); + expect(retPolicy.windows.popup.malware.enabled).toBeFalsy(); + expect(retPolicy.windows.popup.malware.message).toEqual(popupMessage); + expect(retPolicy.mac.popup.malware.message).toEqual(popupMessage); + }); + it('resets Platinum-paid fields for lower license tiers', () => { + const defaults = factory(); // reference + const policy = factory(); // what we will modify, and should be reset + const popupMessage = 'WOOP WOOP'; + policy.windows.popup.malware.message = popupMessage; + policy.mac.popup.malware.message = popupMessage; + policy.windows.popup.malware.enabled = false; + + const retPolicy = unsetPolicyFeaturesAboveLicenseLevel(policy, Gold); + expect(retPolicy.windows.popup.malware.enabled).toEqual( + defaults.windows.popup.malware.enabled + ); + expect(retPolicy.windows.popup.malware.message).not.toEqual(popupMessage); + expect(retPolicy.mac.popup.malware.message).not.toEqual(popupMessage); + + // need to invert the test, since it could be either value + expect(['', DefaultMalwareMessage]).toContain(retPolicy.windows.popup.malware.message); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/license/policy_config.ts b/x-pack/plugins/security_solution/common/license/policy_config.ts new file mode 100644 index 000000000000..da2260ad55e8 --- /dev/null +++ b/x-pack/plugins/security_solution/common/license/policy_config.ts @@ -0,0 +1,66 @@ +/* + * 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 { ILicense } from '../../../licensing/common/types'; +import { isAtLeast } from './license'; +import { PolicyConfig } from '../endpoint/types'; +import { DefaultMalwareMessage, factory } from '../endpoint/models/policy_config'; + +/** + * Given an endpoint package policy, verifies that all enabled features that + * require a certain license level have a valid license for them. + */ +export const isEndpointPolicyValidForLicense = ( + policy: PolicyConfig, + license: ILicense | null +): boolean => { + if (isAtLeast(license, 'platinum')) { + return true; // currently, platinum allows all features + } + + const defaults = factory(); + + // only platinum or higher may disable malware notification + if ( + policy.windows.popup.malware.enabled !== defaults.windows.popup.malware.enabled || + policy.mac.popup.malware.enabled !== defaults.mac.popup.malware.enabled + ) { + return false; + } + + // Only Platinum or higher may change the malware message (which can be blank or what Endpoint defaults) + if ( + [policy.windows, policy.mac].some( + (p) => p.popup.malware.message !== '' && p.popup.malware.message !== DefaultMalwareMessage + ) + ) { + return false; + } + + return true; +}; + +/** + * Resets paid features in a PolicyConfig back to default values + * when unsupported by the given license level. + */ +export const unsetPolicyFeaturesAboveLicenseLevel = ( + policy: PolicyConfig, + license: ILicense | null +): PolicyConfig => { + if (isAtLeast(license, 'platinum')) { + return policy; + } + + const defaults = factory(); + // set any license-gated features back to the defaults + policy.windows.popup.malware.enabled = defaults.windows.popup.malware.enabled; + policy.mac.popup.malware.enabled = defaults.mac.popup.malware.enabled; + policy.windows.popup.malware.message = defaults.windows.popup.malware.message; + policy.mac.popup.malware.message = defaults.mac.popup.malware.message; + + return policy; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts index 36649d22f730..f039324b3af6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts @@ -5,6 +5,7 @@ */ import { IHttpFetchError } from 'kibana/public'; +import { DefaultMalwareMessage } from '../../../../../../common/endpoint/models/policy_config'; import { PolicyDetailsState, UpdatePolicyResponse } from '../../types'; import { policyIdFromParams, @@ -38,10 +39,8 @@ export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory Date: Wed, 25 Nov 2020 13:34:59 -0700 Subject: [PATCH 77/89] [basePathProxy] include query in redirect (#84356) Co-authored-by: spalger --- src/core/server/http/base_path_proxy_server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/server/http/base_path_proxy_server.ts b/src/core/server/http/base_path_proxy_server.ts index 42841377e736..737aab00cff0 100644 --- a/src/core/server/http/base_path_proxy_server.ts +++ b/src/core/server/http/base_path_proxy_server.ts @@ -199,8 +199,13 @@ export class BasePathProxyServer { const isGet = request.method === 'get'; const isBasepathLike = oldBasePath.length === 3; + const newUrl = Url.format({ + pathname: `${this.httpConfig.basePath}/${kbnPath}`, + query: request.query, + }); + return isGet && isBasepathLike && shouldRedirectFromOldBasePath(kbnPath) - ? responseToolkit.redirect(`${this.httpConfig.basePath}/${kbnPath}`) + ? responseToolkit.redirect(newUrl) : responseToolkit.response('Not Found').code(404); }, method: '*', From 459263fd596b91f35c71f025ad9990dde794a9b5 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 25 Nov 2020 13:29:57 -0800 Subject: [PATCH 78/89] [Fleet] Support URL query state in agent logs UI (#84298) * Initial attempt at URL state * Break into smaller files * Handle invalid date range expressions --- .../components/agent_logs/agent_logs.tsx | 278 ++++++++++++++++++ .../components/agent_logs/constants.tsx | 9 + .../components/agent_logs/index.tsx | 277 ++++------------- 3 files changed, 339 insertions(+), 225 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx new file mode 100644 index 000000000000..00deeff89503 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/agent_logs.tsx @@ -0,0 +1,278 @@ +/* + * 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, { memo, useMemo, useState, useCallback, useEffect } from 'react'; +import styled from 'styled-components'; +import url from 'url'; +import { encode } from 'rison-node'; +import { stringify } from 'query-string'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSuperDatePicker, + EuiFilterGroup, + EuiPanel, + EuiButtonEmpty, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import semverGte from 'semver/functions/gte'; +import semverCoerce from 'semver/functions/coerce'; +import { createStateContainerReactHelpers } from '../../../../../../../../../../../src/plugins/kibana_utils/public'; +import { RedirectAppLinks } from '../../../../../../../../../../../src/plugins/kibana_react/public'; +import { TimeRange, esKuery } from '../../../../../../../../../../../src/plugins/data/public'; +import { LogStream } from '../../../../../../../../../infra/public'; +import { Agent } from '../../../../../types'; +import { useStartServices } from '../../../../../hooks'; +import { DEFAULT_DATE_RANGE } from './constants'; +import { DatasetFilter } from './filter_dataset'; +import { LogLevelFilter } from './filter_log_level'; +import { LogQueryBar } from './query_bar'; +import { buildQuery } from './build_query'; +import { SelectLogLevel } from './select_log_level'; + +const WrapperFlexGroup = styled(EuiFlexGroup)` + height: 100%; +`; + +const DatePickerFlexItem = styled(EuiFlexItem)` + max-width: 312px; +`; + +export interface AgentLogsProps { + agent: Agent; + state: AgentLogsState; +} + +export interface AgentLogsState { + start: string; + end: string; + logLevels: string[]; + datasets: string[]; + query: string; +} + +export const AgentLogsUrlStateHelper = createStateContainerReactHelpers(); + +export const AgentLogsUI: React.FunctionComponent = memo(({ agent, state }) => { + const { data, application, http } = useStartServices(); + const { update: updateState } = AgentLogsUrlStateHelper.useTransitions(); + + // Util to convert date expressions (returned by datepicker) to timestamps (used by LogStream) + const getDateRangeTimestamps = useCallback( + (timeRange: TimeRange) => { + const { min, max } = data.query.timefilter.timefilter.calculateBounds(timeRange); + return min && max + ? { + start: min.valueOf(), + end: max.valueOf(), + } + : undefined; + }, + [data.query.timefilter.timefilter] + ); + + const tryUpdateDateRange = useCallback( + (timeRange: TimeRange) => { + const timestamps = getDateRangeTimestamps(timeRange); + if (timestamps) { + updateState({ + start: timeRange.from, + end: timeRange.to, + }); + } + }, + [getDateRangeTimestamps, updateState] + ); + + const [dateRangeTimestamps, setDateRangeTimestamps] = useState<{ start: number; end: number }>( + getDateRangeTimestamps({ + from: state.start, + to: state.end, + }) || + getDateRangeTimestamps({ + from: DEFAULT_DATE_RANGE.start, + to: DEFAULT_DATE_RANGE.end, + })! + ); + + // Attempts to parse for timestamps when start/end date expressions change + // If invalid date expressions, set expressions back to default + // Otherwise set the new timestamps + useEffect(() => { + const timestampsFromDateRange = getDateRangeTimestamps({ + from: state.start, + to: state.end, + }); + if (!timestampsFromDateRange) { + tryUpdateDateRange({ + from: DEFAULT_DATE_RANGE.start, + to: DEFAULT_DATE_RANGE.end, + }); + } else { + setDateRangeTimestamps(timestampsFromDateRange); + } + }, [state.start, state.end, getDateRangeTimestamps, tryUpdateDateRange]); + + // Query validation helper + const isQueryValid = useCallback((testQuery: string) => { + try { + esKuery.fromKueryExpression(testQuery); + return true; + } catch (err) { + return false; + } + }, []); + + // User query state + const [draftQuery, setDraftQuery] = useState(state.query); + const [isDraftQueryValid, setIsDraftQueryValid] = useState(isQueryValid(state.query)); + const onUpdateDraftQuery = useCallback( + (newDraftQuery: string, runQuery?: boolean) => { + setDraftQuery(newDraftQuery); + if (isQueryValid(newDraftQuery)) { + setIsDraftQueryValid(true); + if (runQuery) { + updateState({ query: newDraftQuery }); + } + } else { + setIsDraftQueryValid(false); + } + }, + [isQueryValid, updateState] + ); + + // Build final log stream query from agent id, datasets, log levels, and user input + const logStreamQuery = useMemo( + () => + buildQuery({ + agentId: agent.id, + datasets: state.datasets, + logLevels: state.logLevels, + userQuery: state.query, + }), + [agent.id, state.datasets, state.logLevels, state.query] + ); + + // Generate URL to pass page state to Logs UI + const viewInLogsUrl = useMemo( + () => + http.basePath.prepend( + url.format({ + pathname: '/app/logs/stream', + search: stringify( + { + logPosition: encode({ + start: state.start, + end: state.end, + streamLive: false, + }), + logFilter: encode({ + expression: logStreamQuery, + kind: 'kuery', + }), + }, + { sort: false, encode: false } + ), + }) + ), + [http.basePath, state.start, state.end, logStreamQuery] + ); + + const agentVersion = agent.local_metadata?.elastic?.agent?.version; + const isLogLevelSelectionAvailable = useMemo(() => { + if (!agentVersion) { + return false; + } + const agentVersionWithPrerelease = semverCoerce(agentVersion)?.version; + if (!agentVersionWithPrerelease) { + return false; + } + return semverGte(agentVersionWithPrerelease, '7.11.0'); + }, [agentVersion]); + + return ( + + + + + + + + + { + const currentDatasets = [...state.datasets]; + const datasetPosition = currentDatasets.indexOf(dataset); + if (datasetPosition >= 0) { + currentDatasets.splice(datasetPosition, 1); + updateState({ datasets: currentDatasets }); + } else { + updateState({ datasets: [...state.datasets, dataset] }); + } + }} + /> + { + const currentLevels = [...state.logLevels]; + const levelPosition = currentLevels.indexOf(level); + if (levelPosition >= 0) { + currentLevels.splice(levelPosition, 1); + updateState({ logLevels: currentLevels }); + } else { + updateState({ logLevels: [...state.logLevels, level] }); + } + }} + /> + + + + { + tryUpdateDateRange({ + from: start, + to: end, + }); + }} + /> + + + + + + + + + + + + + + + + {isLogLevelSelectionAvailable && ( + + + + )} + + ); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx index ea98de356024..89fe1a916605 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/constants.tsx @@ -3,6 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { AgentLogsState } from './agent_logs'; + export const AGENT_LOG_INDEX_PATTERN = 'logs-elastic_agent-*,logs-elastic_agent.*-*'; export const AGENT_DATASET = 'elastic_agent'; export const AGENT_DATASET_PATTERN = 'elastic_agent.*'; @@ -24,6 +26,13 @@ export const DEFAULT_DATE_RANGE = { start: 'now-1d', end: 'now', }; +export const DEFAULT_LOGS_STATE: AgentLogsState = { + start: DEFAULT_DATE_RANGE.start, + end: DEFAULT_DATE_RANGE.end, + logLevels: [], + datasets: [AGENT_DATASET], + query: '', +}; export const AGENT_LOG_LEVELS = { ERROR: 'error', diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx index bed857c07309..0d888a88ec2c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/index.tsx @@ -3,236 +3,63 @@ * 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, { memo, useMemo, useState, useCallback } from 'react'; -import styled from 'styled-components'; -import url from 'url'; -import { encode } from 'rison-node'; -import { stringify } from 'query-string'; +import React, { memo, useEffect, useState } from 'react'; import { - EuiFlexGroup, - EuiFlexItem, - EuiSuperDatePicker, - EuiFilterGroup, - EuiPanel, - EuiButtonEmpty, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import semverGte from 'semver/functions/gte'; -import semverCoerce from 'semver/functions/coerce'; -import { RedirectAppLinks } from '../../../../../../../../../../../src/plugins/kibana_react/public'; -import { TimeRange, esKuery } from '../../../../../../../../../../../src/plugins/data/public'; -import { LogStream } from '../../../../../../../../../infra/public'; -import { Agent } from '../../../../../types'; -import { useStartServices } from '../../../../../hooks'; -import { AGENT_DATASET, DEFAULT_DATE_RANGE } from './constants'; -import { DatasetFilter } from './filter_dataset'; -import { LogLevelFilter } from './filter_log_level'; -import { LogQueryBar } from './query_bar'; -import { buildQuery } from './build_query'; -import { SelectLogLevel } from './select_log_level'; + createStateContainer, + syncState, + createKbnUrlStateStorage, + INullableBaseStateContainer, + PureTransition, + getStateFromKbnUrl, +} from '../../../../../../../../../../../src/plugins/kibana_utils/public'; +import { DEFAULT_LOGS_STATE } from './constants'; +import { AgentLogsUI, AgentLogsProps, AgentLogsState, AgentLogsUrlStateHelper } from './agent_logs'; -const WrapperFlexGroup = styled(EuiFlexGroup)` - height: 100%; -`; +const stateStorageKey = '_q'; -const DatePickerFlexItem = styled(EuiFlexItem)` - max-width: 312px; -`; +const stateContainer = createStateContainer< + AgentLogsState, + { + update: PureTransition]>; + } +>( + { + ...DEFAULT_LOGS_STATE, + ...getStateFromKbnUrl(stateStorageKey, window.location.href), + }, + { + update: (state) => (updatedState) => ({ ...state, ...updatedState }), + } +); -export const AgentLogs: React.FunctionComponent<{ agent: Agent }> = memo(({ agent }) => { - const { data, application, http } = useStartServices(); +const AgentLogsConnected = AgentLogsUrlStateHelper.connect((state) => ({ + state: state || DEFAULT_LOGS_STATE, +}))(AgentLogsUI); - // Util to convert date expressions (returned by datepicker) to timestamps (used by LogStream) - const getDateRangeTimestamps = useCallback( - (timeRange: TimeRange) => { - const { min, max } = data.query.timefilter.timefilter.calculateBounds(timeRange); - return min && max - ? { - startTimestamp: min.valueOf(), - endTimestamp: max.valueOf(), - } - : undefined; - }, - [data.query.timefilter.timefilter] - ); +export const AgentLogs: React.FunctionComponent> = memo( + ({ agent }) => { + const [isSyncReady, setIsSyncReady] = useState(false); - // Initial time range filter - const [dateRange, setDateRange] = useState<{ - startExpression: string; - endExpression: string; - startTimestamp: number; - endTimestamp: number; - }>({ - startExpression: DEFAULT_DATE_RANGE.start, - endExpression: DEFAULT_DATE_RANGE.end, - ...getDateRangeTimestamps({ from: DEFAULT_DATE_RANGE.start, to: DEFAULT_DATE_RANGE.end })!, - }); + useEffect(() => { + const stateStorage = createKbnUrlStateStorage(); + const { start, stop } = syncState({ + storageKey: stateStorageKey, + stateContainer: stateContainer as INullableBaseStateContainer, + stateStorage, + }); + start(); + setIsSyncReady(true); - const tryUpdateDateRange = useCallback( - (timeRange: TimeRange) => { - const timestamps = getDateRangeTimestamps(timeRange); - if (timestamps) { - setDateRange({ - startExpression: timeRange.from, - endExpression: timeRange.to, - ...timestamps, - }); - } - }, - [getDateRangeTimestamps] - ); + return () => { + stop(); + stateContainer.set(DEFAULT_LOGS_STATE); + }; + }, []); - // Filters - const [selectedLogLevels, setSelectedLogLevels] = useState([]); - const [selectedDatasets, setSelectedDatasets] = useState([AGENT_DATASET]); - - // User query state - const [query, setQuery] = useState(''); - const [draftQuery, setDraftQuery] = useState(''); - const [isDraftQueryValid, setIsDraftQueryValid] = useState(true); - const onUpdateDraftQuery = useCallback((newDraftQuery: string, runQuery?: boolean) => { - setDraftQuery(newDraftQuery); - try { - esKuery.fromKueryExpression(newDraftQuery); - setIsDraftQueryValid(true); - if (runQuery) { - setQuery(newDraftQuery); - } - } catch (err) { - setIsDraftQueryValid(false); - } - }, []); - - // Build final log stream query from agent id, datasets, log levels, and user input - const logStreamQuery = useMemo( - () => - buildQuery({ - agentId: agent.id, - datasets: selectedDatasets, - logLevels: selectedLogLevels, - userQuery: query, - }), - [agent.id, query, selectedDatasets, selectedLogLevels] - ); - - // Generate URL to pass page state to Logs UI - const viewInLogsUrl = useMemo( - () => - http.basePath.prepend( - url.format({ - pathname: '/app/logs/stream', - search: stringify( - { - logPosition: encode({ - start: dateRange.startExpression, - end: dateRange.endExpression, - streamLive: false, - }), - logFilter: encode({ - expression: logStreamQuery, - kind: 'kuery', - }), - }, - { sort: false, encode: false } - ), - }) - ), - [logStreamQuery, dateRange.endExpression, dateRange.startExpression, http.basePath] - ); - - const agentVersion = agent.local_metadata?.elastic?.agent?.version; - const isLogLevelSelectionAvailable = useMemo(() => { - if (!agentVersion) { - return false; - } - const agentVersionWithPrerelease = semverCoerce(agentVersion)?.version; - if (!agentVersionWithPrerelease) { - return false; - } - return semverGte(agentVersionWithPrerelease, '7.11.0'); - }, [agentVersion]); - - return ( - - - - - - - - - { - const currentLevels = [...selectedDatasets]; - const levelPosition = currentLevels.indexOf(level); - if (levelPosition >= 0) { - currentLevels.splice(levelPosition, 1); - setSelectedDatasets(currentLevels); - } else { - setSelectedDatasets([...selectedDatasets, level]); - } - }} - /> - { - const currentLevels = [...selectedLogLevels]; - const levelPosition = currentLevels.indexOf(level); - if (levelPosition >= 0) { - currentLevels.splice(levelPosition, 1); - setSelectedLogLevels(currentLevels); - } else { - setSelectedLogLevels([...selectedLogLevels, level]); - } - }} - /> - - - - { - tryUpdateDateRange({ - from: start, - to: end, - }); - }} - /> - - - - - - - - - - - - - - - - {isLogLevelSelectionAvailable && ( - - - - )} - - ); -}); + return ( + + {isSyncReady ? : null} + + ); + } +); From fac792778e5dada6c672c9ef082db837629a847e Mon Sep 17 00:00:00 2001 From: Silvia Mitter Date: Thu, 26 Nov 2020 09:32:04 +0100 Subject: [PATCH 79/89] [fleet] Add config options to accepted docker env vars (#84338) --- .../os_packages/docker_generator/resources/bin/kibana-docker | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index 4c833f5be6c5..3e440c89b82d 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -166,6 +166,9 @@ kibana_vars=( xpack.code.security.gitProtocolWhitelist xpack.encryptedSavedObjects.encryptionKey xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys + xpack.fleet.agents.elasticsearch.host + xpack.fleet.agents.kibana.host + xpack.fleet.agents.tlsCheckDisabled xpack.graph.enabled xpack.graph.canEditDrillDownUrls xpack.graph.savePolicy From a4c8dca02147dc3e3f9fef86c4e8f26320ed2058 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 26 Nov 2020 10:08:45 +0100 Subject: [PATCH 80/89] [Uptime] Fix headers io-ts type (#84089) --- x-pack/plugins/uptime/common/runtime_types/ping/ping.ts | 6 +++++- .../uptime/public/components/monitor/ping_list/headers.tsx | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index 9e5cd7641b65..f9dde011b25f 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -86,6 +86,10 @@ export const MonitorType = t.intersection([ export type Monitor = t.TypeOf; +export const PingHeadersType = t.record(t.string, t.union([t.string, t.array(t.string)])); + +export type PingHeaders = t.TypeOf; + export const PingType = t.intersection([ t.type({ timestamp: t.string, @@ -135,7 +139,7 @@ export const PingType = t.intersection([ bytes: t.number, redirects: t.array(t.string), status_code: t.number, - headers: t.record(t.string, t.string), + headers: PingHeadersType, }), version: t.string, }), diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/headers.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/headers.tsx index 52fe26a7e08c..a8cd8e2a26f9 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/headers.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/headers.tsx @@ -7,9 +7,10 @@ import React from 'react'; import { EuiAccordion, EuiDescriptionList, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { PingHeaders as HeadersProp } from '../../../../common/runtime_types'; interface Props { - headers: Record; + headers: HeadersProp; } export const PingHeaders = ({ headers }: Props) => { From d2552c426d6b0385ab075284c06232df8d25ab68 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Thu, 26 Nov 2020 14:08:47 +0300 Subject: [PATCH 81/89] fix identation in list (#84301) --- .../plugin/migrating-legacy-plugins-examples.asciidoc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc index 469f7a4f3adb..8a0e487971b2 100644 --- a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc +++ b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc @@ -902,8 +902,9 @@ The most significant changes on the Kibana side for the consumers are the follow ===== User client accessor Internal /current user client accessors has been renamed and are now properties instead of functions: -** `callAsInternalUser('ping')` -> `asInternalUser.ping()` -** `callAsCurrentUser('ping')` -> `asCurrentUser.ping()` + +* `callAsInternalUser('ping')` -> `asInternalUser.ping()` +* `callAsCurrentUser('ping')` -> `asCurrentUser.ping()` * the API now reflects the `Client`’s instead of leveraging the string-based endpoint names the `LegacyAPICaller` was using. From 4004dd4012b569a72f53d09a482958f62de64383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Thu, 26 Nov 2020 12:25:05 +0100 Subject: [PATCH 82/89] [Application Usage] Update `schema` with new `fleet` rename (#84327) --- .../server/collectors/application_usage/schema.ts | 2 +- src/plugins/telemetry/schema/oss_plugins.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 2e79cdaa7fc6..76141f098a06 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -73,7 +73,7 @@ export const applicationUsageSchema = { logs: commonSchema, metrics: commonSchema, infra: commonSchema, // It's a forward app so we'll likely never report it - ingestManager: commonSchema, + fleet: commonSchema, lens: commonSchema, maps: commonSchema, ml: commonSchema, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index a1eae69ffaed..3d79d7c6cf0e 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -616,7 +616,7 @@ } } }, - "ingestManager": { + "fleet": { "properties": { "clicks_total": { "type": "long" From 36ab99e546ead4f0a3e688d2575512172f94470b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Thu, 26 Nov 2020 14:01:53 +0100 Subject: [PATCH 83/89] [Logs UI] Limit the height of the "view in context" container (#83178) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../infra/public/pages/logs/stream/page_view_log_in_context.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx index 4ac3d15a8222..8a4081288b28 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx @@ -84,6 +84,7 @@ const LogInContextWrapper = euiStyled.div<{ width: number | string; height: numb padding: 16px; width: ${(props) => (typeof props.width === 'number' ? `${props.width}px` : props.width)}; height: ${(props) => (typeof props.height === 'number' ? `${props.height}px` : props.height)}; + max-height: 75vh; // Same as EuiModal `; const LogEntryContext: React.FC<{ context: LogEntry['context'] }> = ({ context }) => { From b246701d39a582b4ea3c32f9a3ca8ed8fcdb2480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Thu, 26 Nov 2020 14:18:42 +0100 Subject: [PATCH 84/89] [Logs UI] Polish the UI for the log entry examples in the anomaly table (#82139) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../log_entry_examples/log_entry_examples.tsx | 1 + .../log_entry_examples_empty_indicator.tsx | 2 +- .../log_entry_examples_failure_indicator.tsx | 2 +- .../sections/anomalies/expanded_row.tsx | 12 +++++++----- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples.tsx index 2ec9922d9455..2d15068e51da 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples.tsx @@ -46,4 +46,5 @@ const Wrapper = euiStyled.div` flex-direction: column; flex: 1 0 0%; overflow: hidden; + padding-top: 1px; // Buffer for the "Reload" buttons' hover state `; diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_empty_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_empty_indicator.tsx index 1d6028ed032a..d3d309948529 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_empty_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_empty_indicator.tsx @@ -10,7 +10,7 @@ import React from 'react'; export const LogEntryExampleMessagesEmptyIndicator: React.FunctionComponent<{ onReload: () => void; }> = ({ onReload }) => ( - + void; }> = ({ onRetry }) => ( - + - +

    {examplesTitle}

    +
    + Date: Thu, 26 Nov 2020 15:45:21 +0100 Subject: [PATCH 85/89] [Discover] Fix navigating back when changing index pattern (#84061) --- .../public/application/angular/discover.js | 6 +++--- .../discover/_indexpattern_without_timefield.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 272c2f2ca618..7059593c0c4e 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -252,7 +252,8 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise if (!_.isEqual(newStatePartial, oldStatePartial)) { $scope.$evalAsync(async () => { if (oldStatePartial.index !== newStatePartial.index) { - //in case of index switch the route has currently to be reloaded, legacy + //in case of index pattern switch the route has currently to be reloaded, legacy + $route.reload(); return; } @@ -289,8 +290,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise $scope.state.sort, config.get(MODIFY_COLUMNS_ON_SWITCH) ); - await replaceUrlAppState(nextAppState); - $route.reload(); + await setAppState(nextAppState); } }; diff --git a/test/functional/apps/discover/_indexpattern_without_timefield.ts b/test/functional/apps/discover/_indexpattern_without_timefield.ts index 677b27c31bd8..20d783690277 100644 --- a/test/functional/apps/discover/_indexpattern_without_timefield.ts +++ b/test/functional/apps/discover/_indexpattern_without_timefield.ts @@ -20,6 +20,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const security = getService('security'); @@ -50,5 +51,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { throw new Error('Expected timepicker to exist'); } }); + it('should switch between with and without timefield using the browser back button', async () => { + await PageObjects.discover.selectIndexPattern('without-timefield'); + if (await PageObjects.timePicker.timePickerExists()) { + throw new Error('Expected timepicker not to exist'); + } + + await PageObjects.discover.selectIndexPattern('with-timefield'); + if (!(await PageObjects.timePicker.timePickerExists())) { + throw new Error('Expected timepicker to exist'); + } + // Navigating back to discover + await browser.goBack(); + if (await PageObjects.timePicker.timePickerExists()) { + throw new Error('Expected timepicker not to exist'); + } + }); }); } From 93b0273ccddb89aed744ef1d64eafd390dcf9090 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 26 Nov 2020 18:04:27 +0300 Subject: [PATCH 86/89] TSVB offsets (#83051) Closes: #40299 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../response_processors/series/time_shift.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/time_shift.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/time_shift.js index 14de6aa18f87..c00b0894073d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/time_shift.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/time_shift.js @@ -17,7 +17,7 @@ * under the License. */ -import _ from 'lodash'; +import { startsWith } from 'lodash'; import moment from 'moment'; export function timeShift(resp, panel, series) { @@ -26,13 +26,15 @@ export function timeShift(resp, panel, series) { const matches = series.offset_time.match(/^([+-]?[\d]+)([shmdwMy]|ms)$/); if (matches) { - const offsetValue = Number(matches[1]); + const offsetValue = matches[1]; const offsetUnit = matches[2]; - const offset = moment.duration(offsetValue, offsetUnit).valueOf(); results.forEach((item) => { - if (_.startsWith(item.id, series.id)) { - item.data = item.data.map(([time, value]) => [time + offset, value]); + if (startsWith(item.id, series.id)) { + item.data = item.data.map((row) => [ + moment.utc(row[0]).add(offsetValue, offsetUnit).valueOf(), + row[1], + ]); } }); } From 4d8b7f9084f46bc4694ea8d78aee2b13a049a80c Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 26 Nov 2020 10:51:10 -0500 Subject: [PATCH 87/89] Improve short-url redirect validation (#84366) --- .../routes/lib/short_url_assert_valid.test.ts | 45 ++++++++++--------- .../routes/lib/short_url_assert_valid.ts | 12 +++-- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/plugins/share/server/routes/lib/short_url_assert_valid.test.ts b/src/plugins/share/server/routes/lib/short_url_assert_valid.test.ts index 02a5e123b648..232429ac4ec3 100644 --- a/src/plugins/share/server/routes/lib/short_url_assert_valid.test.ts +++ b/src/plugins/share/server/routes/lib/short_url_assert_valid.test.ts @@ -19,36 +19,39 @@ import { shortUrlAssertValid } from './short_url_assert_valid'; +const PROTOCOL_ERROR = /^Short url targets cannot have a protocol/; +const HOSTNAME_ERROR = /^Short url targets cannot have a hostname/; +const PATH_ERROR = /^Short url target path must be in the format/; + describe('shortUrlAssertValid()', () => { const invalid = [ - ['protocol', 'http://localhost:5601/app/kibana'], - ['protocol', 'https://localhost:5601/app/kibana'], - ['protocol', 'mailto:foo@bar.net'], - ['protocol', 'javascript:alert("hi")'], // eslint-disable-line no-script-url - ['hostname', 'localhost/app/kibana'], - ['hostname and port', 'local.host:5601/app/kibana'], - ['hostname and auth', 'user:pass@localhost.net/app/kibana'], - ['path traversal', '/app/../../not-kibana'], - ['deep path', '/app/kibana/foo'], - ['deep path', '/app/kibana/foo/bar'], - ['base path', '/base/app/kibana'], + ['protocol', 'http://localhost:5601/app/kibana', PROTOCOL_ERROR], + ['protocol', 'https://localhost:5601/app/kibana', PROTOCOL_ERROR], + ['protocol', 'mailto:foo@bar.net', PROTOCOL_ERROR], + ['protocol', 'javascript:alert("hi")', PROTOCOL_ERROR], // eslint-disable-line no-script-url + ['hostname', 'localhost/app/kibana', PATH_ERROR], // according to spec, this is not a valid URL -- you cannot specify a hostname without a protocol + ['hostname and port', 'local.host:5601/app/kibana', PROTOCOL_ERROR], // parser detects 'local.host' as the protocol + ['hostname and auth', 'user:pass@localhost.net/app/kibana', PROTOCOL_ERROR], // parser detects 'user' as the protocol + ['path traversal', '/app/../../not-kibana', PATH_ERROR], // fails because there are >2 path parts + ['path traversal', '/../not-kibana', PATH_ERROR], // fails because first path part is not 'app' + ['deep path', '/app/kibana/foo', PATH_ERROR], // fails because there are >2 path parts + ['deeper path', '/app/kibana/foo/bar', PATH_ERROR], // fails because there are >2 path parts + ['base path', '/base/app/kibana', PATH_ERROR], // fails because there are >2 path parts + ['path with an extra leading slash', '//foo/app/kibana', HOSTNAME_ERROR], // parser detects 'foo' as the hostname + ['path with an extra leading slash', '///app/kibana', HOSTNAME_ERROR], // parser detects '' as the hostname + ['path without app', '/foo/kibana', PATH_ERROR], // fails because first path part is not 'app' + ['path without appId', '/app/', PATH_ERROR], // fails because there is only one path part (leading and trailing slashes are trimmed) ]; - invalid.forEach(([desc, url]) => { - it(`fails when url has ${desc}`, () => { - try { - shortUrlAssertValid(url); - throw new Error(`expected assertion to throw`); - } catch (err) { - if (!err || !err.isBoom) { - throw err; - } - } + invalid.forEach(([desc, url, error]) => { + it(`fails when url has ${desc as string}`, () => { + expect(() => shortUrlAssertValid(url as string)).toThrowError(error); }); }); const valid = [ '/app/kibana', + '/app/kibana/', // leading and trailing slashes are trimmed '/app/monitoring#angular/route', '/app/text#document-id', '/app/some?with=query', diff --git a/src/plugins/share/server/routes/lib/short_url_assert_valid.ts b/src/plugins/share/server/routes/lib/short_url_assert_valid.ts index 581410359322..773e3acdcb90 100644 --- a/src/plugins/share/server/routes/lib/short_url_assert_valid.ts +++ b/src/plugins/share/server/routes/lib/short_url_assert_valid.ts @@ -22,18 +22,22 @@ import { trim } from 'lodash'; import Boom from '@hapi/boom'; export function shortUrlAssertValid(url: string) { - const { protocol, hostname, pathname } = parse(url); + const { protocol, hostname, pathname } = parse( + url, + false /* parseQueryString */, + true /* slashesDenoteHost */ + ); - if (protocol) { + if (protocol !== null) { throw Boom.notAcceptable(`Short url targets cannot have a protocol, found "${protocol}"`); } - if (hostname) { + if (hostname !== null) { throw Boom.notAcceptable(`Short url targets cannot have a hostname, found "${hostname}"`); } const pathnameParts = trim(pathname === null ? undefined : pathname, '/').split('/'); - if (pathnameParts.length !== 2) { + if (pathnameParts.length !== 2 || pathnameParts[0] !== 'app' || !pathnameParts[1]) { throw Boom.notAcceptable( `Short url target path must be in the format "/app/{{appId}}", found "${pathname}"` ); From 51f75a5655224d77c97b944f8b603ff34ec92714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 26 Nov 2020 17:04:24 +0100 Subject: [PATCH 88/89] Added data streams privileges to better control delete actions in UI (#83573) * Added data streams privileges to better control delete actions in UI * Fix type check issues * Change data streams privileges request * Fixed type check issue * Fixed api integration test * Cleaned up not needed code * Renamed some data streams and added a default value for stats find Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../helpers/test_subjects.ts | 2 + .../home/data_streams_tab.helpers.ts | 12 + .../home/data_streams_tab.test.ts | 73 ++++++ .../common/lib/data_stream_serialization.ts | 2 + .../common/types/data_streams.ts | 16 +- .../data_stream_detail_panel.tsx | 2 +- .../data_stream_list/data_stream_list.tsx | 9 +- .../data_stream_table/data_stream_table.tsx | 6 +- .../server/client/elasticsearch.ts | 22 -- .../plugins/index_management/server/plugin.ts | 3 +- .../component_templates/privileges.test.ts | 2 + .../api/data_streams/register_get_route.ts | 221 ++++++++++++------ .../index_management/server/shared_imports.ts | 2 +- .../plugins/index_management/server/types.ts | 3 +- .../index_management/data_streams.ts | 9 + 15 files changed, 271 insertions(+), 113 deletions(-) diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index 04843cae6a57..e8105ac2937c 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -13,6 +13,8 @@ export type TestSubjects = | 'createTemplateButton' | 'dataStreamsEmptyPromptTemplateLink' | 'dataStreamTable' + | 'deleteDataStreamsButton' + | 'deleteDataStreamButton' | 'deleteSystemTemplateCallOut' | 'deleteTemplateButton' | 'deleteTemplatesConfirmation' diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index 4e0486e55720..9c92af30097a 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -24,6 +24,7 @@ export interface DataStreamsTabTestBed extends TestBed { clickNameAt: (index: number) => void; clickIndicesAt: (index: number) => void; clickDeleteActionAt: (index: number) => void; + selectDataStream: (name: string, selected: boolean) => void; clickConfirmDelete: () => void; clickDeleteDataStreamButton: () => void; clickDetailPanelIndexTemplateLink: () => void; @@ -125,6 +126,13 @@ export const setup = async (overridingDependencies: any = {}): Promise { + const { + form: { selectCheckBox }, + } = testBed; + selectCheckBox(`checkboxSelectRow-${name}`, selected); + }; + const findDeleteConfirmationModal = () => { const { find } = testBed; return find('deleteDataStreamsConfirmation'); @@ -194,6 +202,7 @@ export const setup = async (overridingDependencies: any = {}): Promise): DataSt indexTemplateName: 'indexTemplate', storageSize: '1b', maxTimeStamp: 420, + privileges: { + delete_index: true, + }, ...dataStream, }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index 8ce307c103f4..91502621d50c 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -449,4 +449,77 @@ describe('Data Streams tab', () => { expect(tableCellsValues).toEqual([['', 'non-managed-data-stream', 'green', '1', 'Delete']]); }); }); + + describe('data stream privileges', () => { + describe('delete', () => { + const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; + + const dataStreamWithDelete = createDataStreamPayload({ + name: 'dataStreamWithDelete', + privileges: { delete_index: true }, + }); + const dataStreamNoDelete = createDataStreamPayload({ + name: 'dataStreamNoDelete', + privileges: { delete_index: false }, + }); + + beforeEach(async () => { + setLoadDataStreamsResponse([dataStreamWithDelete, dataStreamNoDelete]); + + testBed = await setup({ history: createMemoryHistory() }); + await act(async () => { + testBed.actions.goToDataStreamsList(); + }); + testBed.component.update(); + }); + + test('displays/hides delete button depending on data streams privileges', async () => { + const { table } = testBed; + const { tableCellsValues } = table.getMetaData('dataStreamTable'); + + expect(tableCellsValues).toEqual([ + ['', 'dataStreamNoDelete', 'green', '1', ''], + ['', 'dataStreamWithDelete', 'green', '1', 'Delete'], + ]); + }); + + test('displays/hides delete action depending on data streams privileges', async () => { + const { + actions: { selectDataStream }, + find, + } = testBed; + + selectDataStream('dataStreamNoDelete', true); + expect(find('deleteDataStreamsButton').exists()).toBeFalsy(); + + selectDataStream('dataStreamWithDelete', true); + expect(find('deleteDataStreamsButton').exists()).toBeFalsy(); + + selectDataStream('dataStreamNoDelete', false); + expect(find('deleteDataStreamsButton').exists()).toBeTruthy(); + }); + + test('displays delete button in detail panel', async () => { + const { + actions: { clickNameAt }, + find, + } = testBed; + setLoadDataStreamResponse(dataStreamWithDelete); + await clickNameAt(1); + + expect(find('deleteDataStreamButton').exists()).toBeTruthy(); + }); + + test('hides delete button in detail panel', async () => { + const { + actions: { clickNameAt }, + find, + } = testBed; + setLoadDataStreamResponse(dataStreamNoDelete); + await clickNameAt(0); + + expect(find('deleteDataStreamButton').exists()).toBeFalsy(); + }); + }); + }); }); diff --git a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts index 2d8e038d2a60..fe7db99c98db 100644 --- a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts @@ -18,6 +18,7 @@ export function deserializeDataStream(dataStreamFromEs: DataStreamFromEs): DataS store_size: storageSize, maximum_timestamp: maxTimeStamp, _meta, + privileges, } = dataStreamFromEs; return { @@ -37,6 +38,7 @@ export function deserializeDataStream(dataStreamFromEs: DataStreamFromEs): DataS storageSize, maxTimeStamp, _meta, + privileges, }; } diff --git a/x-pack/plugins/index_management/common/types/data_streams.ts b/x-pack/plugins/index_management/common/types/data_streams.ts index adb7104043fb..fdfe6278eb98 100644 --- a/x-pack/plugins/index_management/common/types/data_streams.ts +++ b/x-pack/plugins/index_management/common/types/data_streams.ts @@ -10,13 +10,19 @@ interface TimestampFieldFromEs { type TimestampField = TimestampFieldFromEs; -interface MetaFieldFromEs { +interface MetaFromEs { managed_by: string; package: any; managed: boolean; } -type MetaField = MetaFieldFromEs; +type Meta = MetaFromEs; + +interface PrivilegesFromEs { + delete_index: boolean; +} + +type Privileges = PrivilegesFromEs; export type HealthFromEs = 'GREEN' | 'YELLOW' | 'RED'; @@ -25,12 +31,13 @@ export interface DataStreamFromEs { timestamp_field: TimestampFieldFromEs; indices: DataStreamIndexFromEs[]; generation: number; - _meta?: MetaFieldFromEs; + _meta?: MetaFromEs; status: HealthFromEs; template: string; ilm_policy?: string; store_size?: string; maximum_timestamp?: number; + privileges: PrivilegesFromEs; } export interface DataStreamIndexFromEs { @@ -50,7 +57,8 @@ export interface DataStream { ilmPolicyName?: string; storageSize?: string; maxTimeStamp?: number; - _meta?: MetaField; + _meta?: Meta; + privileges: Privileges; } export interface DataStreamIndex { diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index 05d7e97745b9..ec47b2c062aa 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -290,7 +290,7 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ - {!isLoading && !error ? ( + {!isLoading && !error && dataStream?.privileges.delete_index ? ( { const { isDeepLink } = extractQueryParams(search); + const decodedDataStreamName = attemptToURIDecode(dataStreamName); const { core: { getUrlForApp }, @@ -241,8 +242,8 @@ export const DataStreamList: React.FunctionComponent { history.push(`/${Section.DataStreams}`); diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx index c1fd33a39569..7a3e719d013c 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx @@ -162,6 +162,7 @@ export const DataStreamTable: React.FunctionComponent = ({ }, isPrimary: true, 'data-test-subj': 'deleteDataStream', + available: ({ privileges: { delete_index: deleteIndex } }: DataStream) => deleteIndex, }, ], }); @@ -188,9 +189,10 @@ export const DataStreamTable: React.FunctionComponent = ({ incremental: true, }, toolsLeft: - selection.length > 0 ? ( + selection.length > 0 && + selection.every((dataStream: DataStream) => dataStream.privileges.delete_index) ? ( setDataStreamsToDelete(selection.map(({ name }: DataStream) => name))} color="danger" > diff --git a/x-pack/plugins/index_management/server/client/elasticsearch.ts b/x-pack/plugins/index_management/server/client/elasticsearch.ts index ed5ede07479c..8b7749335131 100644 --- a/x-pack/plugins/index_management/server/client/elasticsearch.ts +++ b/x-pack/plugins/index_management/server/client/elasticsearch.ts @@ -11,28 +11,6 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) const dataManagement = Client.prototype.dataManagement.prototype; // Data streams - dataManagement.getDataStreams = ca({ - urls: [ - { - fmt: '/_data_stream', - }, - ], - method: 'GET', - }); - - dataManagement.getDataStream = ca({ - urls: [ - { - fmt: '/_data_stream/<%=name%>', - req: { - name: { - type: 'string', - }, - }, - }, - ], - method: 'GET', - }); // We don't allow the user to create a data stream in the UI or API. We're just adding this here // to enable the API integration tests. diff --git a/x-pack/plugins/index_management/server/plugin.ts b/x-pack/plugins/index_management/server/plugin.ts index ae9633f3e22b..3d70140fa60b 100644 --- a/x-pack/plugins/index_management/server/plugin.ts +++ b/x-pack/plugins/index_management/server/plugin.ts @@ -24,7 +24,7 @@ import { PLUGIN } from '../common'; import { Dependencies } from './types'; import { ApiRoutes } from './routes'; import { License, IndexDataEnricher } from './services'; -import { isEsError } from './shared_imports'; +import { isEsError, handleEsError } from './shared_imports'; import { elasticsearchJsPlugin } from './client/elasticsearch'; export interface DataManagementContext { @@ -110,6 +110,7 @@ export class IndexMgmtServerPlugin implements Plugin { indexDataEnricher: mockedIndexDataEnricher, lib: { isEsError: jest.fn(), + handleEsError: jest.fn(), }, }); @@ -123,6 +124,7 @@ describe('GET privileges', () => { indexDataEnricher: mockedIndexDataEnricher, lib: { isEsError: jest.fn(), + handleEsError: jest.fn(), }, }); diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts index fa93d9bd0c56..d19383d892cb 100644 --- a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts @@ -6,122 +6,189 @@ import { schema, TypeOf } from '@kbn/config-schema'; +import { ElasticsearchClient } from 'kibana/server'; import { deserializeDataStream, deserializeDataStreamList } from '../../../../common/lib'; +import { DataStreamFromEs } from '../../../../common/types'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../index'; -const querySchema = schema.object({ - includeStats: schema.maybe(schema.oneOf([schema.literal('true'), schema.literal('false')])), -}); +interface PrivilegesFromEs { + username: string; + has_all_requested: boolean; + cluster: Record; + index: Record>; + application: Record; +} + +interface StatsFromEs { + data_stream: string; + store_size: string; + maximum_timestamp: number; +} -export function registerGetAllRoute({ router, license, lib: { isEsError } }: RouteDependencies) { +const enhanceDataStreams = ({ + dataStreams, + dataStreamsStats, + dataStreamsPrivileges, +}: { + dataStreams: DataStreamFromEs[]; + dataStreamsStats?: StatsFromEs[]; + dataStreamsPrivileges?: PrivilegesFromEs; +}): DataStreamFromEs[] => { + return dataStreams.map((dataStream: DataStreamFromEs) => { + let enhancedDataStream = { ...dataStream }; + + if (dataStreamsStats) { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { store_size, maximum_timestamp } = + dataStreamsStats.find( + ({ data_stream: statsName }: { data_stream: string }) => statsName === dataStream.name + ) || {}; + + enhancedDataStream = { + ...enhancedDataStream, + store_size, + maximum_timestamp, + }; + } + + enhancedDataStream = { + ...enhancedDataStream, + privileges: { + delete_index: dataStreamsPrivileges + ? dataStreamsPrivileges.index[dataStream.name].delete_index + : true, + }, + }; + + return enhancedDataStream; + }); +}; + +const getDataStreamsStats = (client: ElasticsearchClient, name = '*') => { + return client.transport.request({ + path: `/_data_stream/${encodeURIComponent(name)}/_stats`, + method: 'GET', + querystring: { + human: true, + }, + }); +}; + +const getDataStreamsPrivileges = (client: ElasticsearchClient, names: string[]) => { + return client.security.hasPrivileges({ + body: { + index: [ + { + names, + privileges: ['delete_index'], + }, + ], + }, + }); +}; + +export function registerGetAllRoute({ + router, + license, + lib: { handleEsError }, + config, +}: RouteDependencies) { + const querySchema = schema.object({ + includeStats: schema.maybe(schema.oneOf([schema.literal('true'), schema.literal('false')])), + }); router.get( { path: addBasePath('/data_streams'), validate: { query: querySchema } }, - license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.dataManagement!.client; + license.guardApiRoute(async (ctx, req, response) => { + const { asCurrentUser } = ctx.core.elasticsearch.client; const includeStats = (req.query as TypeOf).includeStats === 'true'; try { - const { data_streams: dataStreams } = await callAsCurrentUser( - 'dataManagement.getDataStreams' - ); - - if (includeStats) { - const { - data_streams: dataStreamsStats, - } = await ctx.core.elasticsearch.legacy.client.callAsCurrentUser('transport.request', { - path: '/_data_stream/*/_stats', - method: 'GET', - query: { - human: true, - }, - }); + let { + body: { data_streams: dataStreams }, + } = await asCurrentUser.indices.getDataStream(); - // Merge stats into data streams. - for (let i = 0; i < dataStreams.length; i++) { - const dataStream = dataStreams[i]; + let dataStreamsStats; + let dataStreamsPrivileges; - // eslint-disable-next-line @typescript-eslint/naming-convention - const { store_size, maximum_timestamp } = dataStreamsStats.find( - ({ data_stream: statsName }: { data_stream: string }) => statsName === dataStream.name - ); - - dataStreams[i] = { - ...dataStream, - store_size, - maximum_timestamp, - }; - } + if (includeStats) { + ({ + body: { data_streams: dataStreamsStats }, + } = await getDataStreamsStats(asCurrentUser)); } - return res.ok({ body: deserializeDataStreamList(dataStreams) }); - } catch (error) { - if (isEsError(error)) { - return res.customError({ - statusCode: error.statusCode, - body: error, - }); + if (config.isSecurityEnabled() && dataStreams.length > 0) { + ({ body: dataStreamsPrivileges } = await getDataStreamsPrivileges( + asCurrentUser, + dataStreams.map((dataStream: DataStreamFromEs) => dataStream.name) + )); } - return res.internalError({ body: error }); + dataStreams = enhanceDataStreams({ + dataStreams, + dataStreamsStats, + dataStreamsPrivileges, + }); + + return response.ok({ body: deserializeDataStreamList(dataStreams) }); + } catch (error) { + return handleEsError({ error, response }); } }) ); } -export function registerGetOneRoute({ router, license, lib: { isEsError } }: RouteDependencies) { +export function registerGetOneRoute({ + router, + license, + lib: { handleEsError }, + config, +}: RouteDependencies) { const paramsSchema = schema.object({ name: schema.string(), }); - router.get( { path: addBasePath('/data_streams/{name}'), validate: { params: paramsSchema }, }, - license.guardApiRoute(async (ctx, req, res) => { + license.guardApiRoute(async (ctx, req, response) => { const { name } = req.params as TypeOf; - const { callAsCurrentUser } = ctx.dataManagement!.client; + const { asCurrentUser } = ctx.core.elasticsearch.client; try { const [ - { data_streams: dataStream }, - { data_streams: dataStreamsStats }, + { + body: { data_streams: dataStreams }, + }, + { + body: { data_streams: dataStreamsStats }, + }, ] = await Promise.all([ - callAsCurrentUser('dataManagement.getDataStream', { - name, - }), - ctx.core.elasticsearch.legacy.client.callAsCurrentUser('transport.request', { - path: `/_data_stream/${encodeURIComponent(name)}/_stats`, - method: 'GET', - query: { - human: true, - }, - }), + asCurrentUser.indices.getDataStream({ name }), + getDataStreamsStats(asCurrentUser, name), ]); - if (dataStream[0]) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { store_size, maximum_timestamp } = dataStreamsStats[0]; - dataStream[0] = { - ...dataStream[0], - store_size, - maximum_timestamp, - }; - const body = deserializeDataStream(dataStream[0]); - return res.ok({ body }); - } + if (dataStreams[0]) { + let dataStreamsPrivileges; + if (config.isSecurityEnabled()) { + ({ body: dataStreamsPrivileges } = await getDataStreamsPrivileges(asCurrentUser, [ + dataStreams[0].name, + ])); + } - return res.notFound(); - } catch (e) { - if (isEsError(e)) { - return res.customError({ - statusCode: e.statusCode, - body: e, + const enhancedDataStreams = enhanceDataStreams({ + dataStreams, + dataStreamsStats, + dataStreamsPrivileges, }); + const body = deserializeDataStream(enhancedDataStreams[0]); + return response.ok({ body }); } - // Case: default - return res.internalError({ body: e }); + + return response.notFound(); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_management/server/shared_imports.ts b/x-pack/plugins/index_management/server/shared_imports.ts index 454beda5394c..0606f474897b 100644 --- a/x-pack/plugins/index_management/server/shared_imports.ts +++ b/x-pack/plugins/index_management/server/shared_imports.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { isEsError } from '../../../../src/plugins/es_ui_shared/server'; +export { isEsError, handleEsError } from '../../../../src/plugins/es_ui_shared/server'; diff --git a/x-pack/plugins/index_management/server/types.ts b/x-pack/plugins/index_management/server/types.ts index 7aa91629f0a4..177dedeb87bb 100644 --- a/x-pack/plugins/index_management/server/types.ts +++ b/x-pack/plugins/index_management/server/types.ts @@ -8,7 +8,7 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../features/serve import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; import { License, IndexDataEnricher } from './services'; -import { isEsError } from './shared_imports'; +import { isEsError, handleEsError } from './shared_imports'; export interface Dependencies { security: SecurityPluginSetup; @@ -25,6 +25,7 @@ export interface RouteDependencies { indexDataEnricher: IndexDataEnricher; lib: { isEsError: typeof isEsError; + handleEsError: typeof handleEsError; }; } diff --git a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts index f4b947336e04..6cf1a40a4d5a 100644 --- a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts +++ b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts @@ -77,6 +77,9 @@ export default function ({ getService }: FtrProviderContext) { expect(dataStreams).to.eql([ { name: testDataStreamName, + privileges: { + delete_index: true, + }, timeStampField: { name: '@timestamp' }, indices: [ { @@ -105,6 +108,9 @@ export default function ({ getService }: FtrProviderContext) { expect(dataStreams.length).to.be(1); expect(dataStreamWithoutStorageSize).to.eql({ name: testDataStreamName, + privileges: { + delete_index: true, + }, timeStampField: { name: '@timestamp' }, indices: [ { @@ -132,6 +138,9 @@ export default function ({ getService }: FtrProviderContext) { expect(dataStreamWithoutStorageSize).to.eql({ name: testDataStreamName, + privileges: { + delete_index: true, + }, timeStampField: { name: '@timestamp' }, indices: [ { From ee5c9bceebf1b0ddd3f4da371e8009d455e7b3f6 Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Thu, 26 Nov 2020 20:34:06 +0100 Subject: [PATCH 89/89] Upgrade fp-ts to 2.8.6 (#83866) * Upgrade fp-ts to 2.8.6 * reduce import size from io-ts * removed unused imports * remove usage of fpts from alerts Co-authored-by: Gidi Meir Morris Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/alerts/public/alert_api.test.ts | 66 +------------------ x-pack/plugins/alerts/public/alert_api.ts | 41 ++---------- .../public/application/lib/alert_api.ts | 6 +- yarn.lock | 6 +- 4 files changed, 14 insertions(+), 105 deletions(-) diff --git a/x-pack/plugins/alerts/public/alert_api.test.ts b/x-pack/plugins/alerts/public/alert_api.test.ts index 3ee67b79b7bd..0fa2e7f25323 100644 --- a/x-pack/plugins/alerts/public/alert_api.test.ts +++ b/x-pack/plugins/alerts/public/alert_api.test.ts @@ -6,7 +6,7 @@ import { AlertType } from '../common'; import { httpServiceMock } from '../../../../src/core/public/mocks'; -import { loadAlert, loadAlertState, loadAlertType, loadAlertTypes } from './alert_api'; +import { loadAlert, loadAlertType, loadAlertTypes } from './alert_api'; import uuid from 'uuid'; const http = httpServiceMock.createStartContract(); @@ -114,67 +114,3 @@ describe('loadAlert', () => { expect(http.get).toHaveBeenCalledWith(`/api/alerts/alert/${alertId}`); }); }); - -describe('loadAlertState', () => { - test('should call get API with base parameters', async () => { - const alertId = uuid.v4(); - const resolvedValue = { - alertTypeState: { - some: 'value', - }, - alertInstances: { - first_instance: {}, - second_instance: {}, - }, - }; - http.get.mockResolvedValueOnce(resolvedValue); - - expect(await loadAlertState({ http, alertId })).toEqual(resolvedValue); - expect(http.get).toHaveBeenCalledWith(`/api/alerts/alert/${alertId}/state`); - }); - - test('should parse AlertInstances', async () => { - const alertId = uuid.v4(); - const resolvedValue = { - alertTypeState: { - some: 'value', - }, - alertInstances: { - first_instance: { - state: {}, - meta: { - lastScheduledActions: { - group: 'first_group', - date: '2020-02-09T23:15:41.941Z', - }, - }, - }, - }, - }; - http.get.mockResolvedValueOnce(resolvedValue); - - expect(await loadAlertState({ http, alertId })).toEqual({ - ...resolvedValue, - alertInstances: { - first_instance: { - state: {}, - meta: { - lastScheduledActions: { - group: 'first_group', - date: new Date('2020-02-09T23:15:41.941Z'), - }, - }, - }, - }, - }); - expect(http.get).toHaveBeenCalledWith(`/api/alerts/alert/${alertId}/state`); - }); - - test('should handle empty response from api', async () => { - const alertId = uuid.v4(); - http.get.mockResolvedValueOnce(''); - - expect(await loadAlertState({ http, alertId })).toEqual({}); - expect(http.get).toHaveBeenCalledWith(`/api/alerts/alert/${alertId}/state`); - }); -}); diff --git a/x-pack/plugins/alerts/public/alert_api.ts b/x-pack/plugins/alerts/public/alert_api.ts index 5b7cd2128f38..753158ed1ed4 100644 --- a/x-pack/plugins/alerts/public/alert_api.ts +++ b/x-pack/plugins/alerts/public/alert_api.ts @@ -5,15 +5,9 @@ */ import { HttpSetup } from 'kibana/public'; -import * as t from 'io-ts'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { findFirst } from 'fp-ts/lib/Array'; -import { isNone } from 'fp-ts/lib/Option'; - import { i18n } from '@kbn/i18n'; -import { BASE_ALERT_API_PATH, alertStateSchema } from '../common'; -import { Alert, AlertType, AlertTaskState } from '../common'; +import { BASE_ALERT_API_PATH } from '../common'; +import type { Alert, AlertType } from '../common'; export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { return await http.get(`${BASE_ALERT_API_PATH}/list_alert_types`); @@ -26,10 +20,10 @@ export async function loadAlertType({ http: HttpSetup; id: AlertType['id']; }): Promise { - const maybeAlertType = findFirst((type) => type.id === id)( - await http.get(`${BASE_ALERT_API_PATH}/list_alert_types`) - ); - if (isNone(maybeAlertType)) { + const maybeAlertType = ((await http.get( + `${BASE_ALERT_API_PATH}/list_alert_types` + )) as AlertType[]).find((type) => type.id === id); + if (!maybeAlertType) { throw new Error( i18n.translate('xpack.alerts.loadAlertType.missingAlertTypeError', { defaultMessage: 'Alert type "{id}" is not registered.', @@ -39,7 +33,7 @@ export async function loadAlertType({ }) ); } - return maybeAlertType.value; + return maybeAlertType; } export async function loadAlert({ @@ -51,24 +45,3 @@ export async function loadAlert({ }): Promise { return await http.get(`${BASE_ALERT_API_PATH}/alert/${alertId}`); } - -type EmptyHttpResponse = ''; -export async function loadAlertState({ - http, - alertId, -}: { - http: HttpSetup; - alertId: string; -}): Promise { - return await http - .get(`${BASE_ALERT_API_PATH}/alert/${alertId}/state`) - .then((state: AlertTaskState | EmptyHttpResponse) => (state ? state : {})) - .then((state: AlertTaskState) => { - return pipe( - alertStateSchema.decode(state), - fold((e: t.Errors) => { - throw new Error(`Alert "${alertId}" has invalid state`); - }, t.identity) - ); - }); -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index 7c2f50211d4a..d34481850ca4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -5,7 +5,7 @@ */ import { HttpSetup } from 'kibana/public'; -import * as t from 'io-ts'; +import { Errors, identity } from 'io-ts'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { pick } from 'lodash'; @@ -48,9 +48,9 @@ export async function loadAlertState({ .then((state: AlertTaskState) => { return pipe( alertStateSchema.decode(state), - fold((e: t.Errors) => { + fold((e: Errors) => { throw new Error(`Alert "${alertId}" has invalid state`); - }, t.identity) + }, identity) ); }); } diff --git a/yarn.lock b/yarn.lock index 1cde1266ca38..1f5eba54fb83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14045,9 +14045,9 @@ fp-ts@^1.0.0: integrity sha512-fWwnAgVlTsV26Ruo9nx+fxNHIm6l1puE1VJ/C0XJ3nRQJJJIgRHYw6sigB3MuNFZL1o4fpGlhwFhcbxHK0RsOA== fp-ts@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.3.1.tgz#8068bfcca118227932941101e062134d7ecd9119" - integrity sha512-KevPBnYt0aaJiuUzmU9YIxjrhC9AgJ8CLtLlXmwArovlNTeYM5NtEoKd86B0wHd7FIbzeE8sNXzCoYIOr7e6Iw== + version "2.8.6" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.8.6.tgz#1a0e6c3f29f5b0fbfa3120f034ea266aa73c811b" + integrity sha512-fGGpKf/Jy3UT4s16oM+hr/8F5QXFcZ+20NAvaZXH5Y5jsiLPMDCaNqffXq0z1Kr6ZUJj0346cH9tq+cI2SoJ4w== fragment-cache@^0.2.1: version "0.2.1"