From 7f8f89ed99fb5feeab69eda87cb59a30003eed02 Mon Sep 17 00:00:00 2001
From: Kevin Logan <56395104+kevinlog@users.noreply.github.com>
Date: Wed, 2 Jun 2021 07:29:32 -0400
Subject: [PATCH 01/77] [Security Solution] Add Ransomware canary advanced
policy option (#101068)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../pages/policy/models/advanced_policy_schema.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts
index 3c760545539c15..166d3f3b98a859 100644
--- a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts
+++ b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts
@@ -648,4 +648,14 @@ export const AdvancedPolicySchema: AdvancedPolicySchemaType[] = [
}
),
},
+ {
+ key: 'windows.advanced.ransomware.canary',
+ first_supported_version: '7.14',
+ documentation: i18n.translate(
+ 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.ransomware.canary',
+ {
+ defaultMessage: "A value of 'false' disables Ransomware canary protection. Default: true.",
+ }
+ ),
+ },
];
From 14442b78defdb6b4fb2d3c52ecedb2f28e2483c7 Mon Sep 17 00:00:00 2001
From: Nathan Reese
Date: Wed, 2 Jun 2021 06:17:23 -0600
Subject: [PATCH 02/77] [Maps] spatially filter by all geo fields (#100735)
* [Maps] spatial filter by all geo fields
* replace geoFields with geoFieldNames
* update mapSpatialFilter to be able to reconize multi field filters
* add check for geoFieldNames
* i18n fixes and fix GeometryFilterForm jest test
* tslint
* tslint
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../lib/mappers/map_spatial_filter.test.ts | 55 ++
.../lib/mappers/map_spatial_filter.ts | 13 +
.../common/descriptor_types/map_descriptor.ts | 5 +-
.../elasticsearch_geo_utils.test.js | 218 -------
.../elasticsearch_geo_utils.ts | 218 +------
.../maps/common/elasticsearch_util/index.ts | 2 +
.../spatial_filter_utils.test.ts | 534 ++++++++++++++++++
.../spatial_filter_utils.ts | 221 ++++++++
.../maps/common/elasticsearch_util/types.ts | 54 ++
.../geometry_filter_form.test.js.snap | 226 +-------
.../components/distance_filter_form.tsx | 34 +-
.../public/components/geo_field_with_index.ts | 19 -
.../public/components/geometry_filter_form.js | 36 +-
.../components/geometry_filter_form.test.js | 68 +--
.../multi_index_geo_field_select.tsx | 79 ---
.../map_container/map_container.tsx | 48 +-
.../draw_filter_control.tsx | 14 +-
.../draw_control/draw_filter_control/index.ts | 7 +-
.../connected_components/mb_map/mb_map.tsx | 3 -
.../feature_geometry_filter_form.tsx | 11 +-
.../mb_map/tooltip_control/index.ts | 2 +
.../tooltip_control/tooltip_control.test.tsx | 2 +-
.../tooltip_control/tooltip_control.tsx | 37 +-
.../toolbar_overlay.test.tsx.snap | 13 +-
.../toolbar_overlay/index.ts | 14 +-
.../toolbar_overlay/toolbar_overlay.test.tsx | 14 +-
.../toolbar_overlay/toolbar_overlay.tsx | 6 +-
.../__snapshots__/tools_control.test.tsx.snap | 60 --
.../tools_control/tools_control.tsx | 15 +-
.../maps/public/embeddable/map_embeddable.tsx | 1 -
.../translations/translations/ja-JP.json | 2 -
.../translations/translations/zh-CN.json | 2 -
32 files changed, 942 insertions(+), 1091 deletions(-)
create mode 100644 x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.test.ts
create mode 100644 x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.ts
create mode 100644 x-pack/plugins/maps/common/elasticsearch_util/types.ts
delete mode 100644 x-pack/plugins/maps/public/components/geo_field_with_index.ts
delete mode 100644 x-pack/plugins/maps/public/components/multi_index_geo_field_select.tsx
diff --git a/src/plugins/data/public/query/filter_manager/lib/mappers/map_spatial_filter.test.ts b/src/plugins/data/public/query/filter_manager/lib/mappers/map_spatial_filter.test.ts
index 479cbb140fc709..0ae1513ae5d1b7 100644
--- a/src/plugins/data/public/query/filter_manager/lib/mappers/map_spatial_filter.test.ts
+++ b/src/plugins/data/public/query/filter_manager/lib/mappers/map_spatial_filter.test.ts
@@ -54,6 +54,61 @@ describe('mapSpatialFilter()', () => {
expect(result).toHaveProperty('type', FILTERS.SPATIAL_FILTER);
});
+ test('should return the key for matching multi field filter', async () => {
+ const filter = {
+ meta: {
+ alias: 'my spatial filter',
+ isMultiIndex: true,
+ type: FILTERS.SPATIAL_FILTER,
+ } as FilterMeta,
+ query: {
+ bool: {
+ should: [
+ {
+ bool: {
+ must: [
+ {
+ exists: {
+ field: 'geo.coordinates',
+ },
+ },
+ {
+ geo_distance: {
+ distance: '1000km',
+ 'geo.coordinates': [120, 30],
+ },
+ },
+ ],
+ },
+ },
+ {
+ bool: {
+ must: [
+ {
+ exists: {
+ field: 'location',
+ },
+ },
+ {
+ geo_distance: {
+ distance: '1000km',
+ location: [120, 30],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ } as Filter;
+ const result = mapSpatialFilter(filter);
+
+ expect(result).toHaveProperty('key', 'query');
+ expect(result).toHaveProperty('value', '');
+ expect(result).toHaveProperty('type', FILTERS.SPATIAL_FILTER);
+ });
+
test('should return undefined for none matching', async (done) => {
const filter = {
meta: {
diff --git a/src/plugins/data/public/query/filter_manager/lib/mappers/map_spatial_filter.ts b/src/plugins/data/public/query/filter_manager/lib/mappers/map_spatial_filter.ts
index 0703bc055a39b9..229257c1a7d81f 100644
--- a/src/plugins/data/public/query/filter_manager/lib/mappers/map_spatial_filter.ts
+++ b/src/plugins/data/public/query/filter_manager/lib/mappers/map_spatial_filter.ts
@@ -22,5 +22,18 @@ export const mapSpatialFilter = (filter: Filter) => {
value: '',
};
}
+
+ if (
+ filter.meta &&
+ filter.meta.type === FILTERS.SPATIAL_FILTER &&
+ filter.meta.isMultiIndex &&
+ filter.query?.bool?.should
+ ) {
+ return {
+ key: 'query',
+ type: filter.meta.type,
+ value: '',
+ };
+ }
throw filter;
};
diff --git a/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts b/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts
index 102434ffda1612..4092ba49888bc1 100644
--- a/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts
+++ b/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts
@@ -11,7 +11,7 @@ import { ReactNode } from 'react';
import { GeoJsonProperties } from 'geojson';
import { Geometry } from 'geojson';
import { Query } from '../../../../../src/plugins/data/common';
-import { DRAW_TYPE, ES_GEO_FIELD_TYPE, ES_SPATIAL_RELATIONS } from '../constants';
+import { DRAW_TYPE, ES_SPATIAL_RELATIONS } from '../constants';
export type MapExtent = {
minLon: number;
@@ -70,9 +70,6 @@ export type DrawState = {
actionId: string;
drawType: DRAW_TYPE;
filterLabel?: string; // point radius filter alias
- geoFieldName?: string;
- geoFieldType?: ES_GEO_FIELD_TYPE;
geometryLabel?: string;
- indexPatternId?: string;
relation?: ES_SPATIAL_RELATIONS;
};
diff --git a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.test.js b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.test.js
index c2ca952c3e8c93..e6b27e78fd36b1 100644
--- a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.test.js
+++ b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.test.js
@@ -9,9 +9,7 @@ import {
hitsToGeoJson,
geoPointToGeometry,
geoShapeToGeometry,
- createExtentFilter,
roundCoordinates,
- extractFeaturesFromFilters,
makeESBbox,
scaleBounds,
} from './elasticsearch_geo_utils';
@@ -388,94 +386,6 @@ describe('geoShapeToGeometry', () => {
});
});
-describe('createExtentFilter', () => {
- it('should return elasticsearch geo_bounding_box filter', () => {
- const mapExtent = {
- maxLat: 39,
- maxLon: -83,
- minLat: 35,
- minLon: -89,
- };
- const filter = createExtentFilter(mapExtent, [geoFieldName]);
- expect(filter.geo_bounding_box).toEqual({
- location: {
- top_left: [-89, 39],
- bottom_right: [-83, 35],
- },
- });
- });
-
- it('should clamp longitudes to -180 to 180 and latitudes to -90 to 90', () => {
- const mapExtent = {
- maxLat: 120,
- maxLon: 200,
- minLat: -100,
- minLon: -190,
- };
- const filter = createExtentFilter(mapExtent, [geoFieldName]);
- expect(filter.geo_bounding_box).toEqual({
- location: {
- top_left: [-180, 89],
- bottom_right: [180, -89],
- },
- });
- });
-
- it('should make left longitude greater than right longitude when area crosses 180 meridian east to west', () => {
- const mapExtent = {
- maxLat: 39,
- maxLon: 200,
- minLat: 35,
- minLon: 100,
- };
- const filter = createExtentFilter(mapExtent, [geoFieldName]);
- const leftLon = filter.geo_bounding_box.location.top_left[0];
- const rightLon = filter.geo_bounding_box.location.bottom_right[0];
- expect(leftLon).toBeGreaterThan(rightLon);
- expect(filter.geo_bounding_box).toEqual({
- location: {
- top_left: [100, 39],
- bottom_right: [-160, 35],
- },
- });
- });
-
- it('should make left longitude greater than right longitude when area crosses 180 meridian west to east', () => {
- const mapExtent = {
- maxLat: 39,
- maxLon: -100,
- minLat: 35,
- minLon: -200,
- };
- const filter = createExtentFilter(mapExtent, [geoFieldName]);
- const leftLon = filter.geo_bounding_box.location.top_left[0];
- const rightLon = filter.geo_bounding_box.location.bottom_right[0];
- expect(leftLon).toBeGreaterThan(rightLon);
- expect(filter.geo_bounding_box).toEqual({
- location: {
- top_left: [160, 39],
- bottom_right: [-100, 35],
- },
- });
- });
-
- it('should clamp longitudes to -180 to 180 when longitude wraps globe', () => {
- const mapExtent = {
- maxLat: 39,
- maxLon: 209,
- minLat: 35,
- minLon: -191,
- };
- const filter = createExtentFilter(mapExtent, [geoFieldName]);
- expect(filter.geo_bounding_box).toEqual({
- location: {
- top_left: [-180, 39],
- bottom_right: [180, 35],
- },
- });
- });
-});
-
describe('roundCoordinates', () => {
it('should set coordinates precision', () => {
const coordinates = [
@@ -492,134 +402,6 @@ describe('roundCoordinates', () => {
});
});
-describe('extractFeaturesFromFilters', () => {
- it('should ignore non-spatial filers', () => {
- const phraseFilter = {
- meta: {
- alias: null,
- disabled: false,
- index: '90943e30-9a47-11e8-b64d-95841ca0b247',
- key: 'machine.os',
- negate: false,
- params: {
- query: 'ios',
- },
- type: 'phrase',
- },
- query: {
- match_phrase: {
- 'machine.os': 'ios',
- },
- },
- };
- expect(extractFeaturesFromFilters([phraseFilter])).toEqual([]);
- });
-
- it('should convert geo_distance filter to feature', () => {
- const spatialFilter = {
- geo_distance: {
- distance: '1096km',
- 'geo.coordinates': [-89.87125, 53.49454],
- },
- meta: {
- alias: 'geo.coordinates within 1096km of -89.87125,53.49454',
- disabled: false,
- index: '90943e30-9a47-11e8-b64d-95841ca0b247',
- key: 'geo.coordinates',
- negate: false,
- type: 'spatial_filter',
- value: '',
- },
- };
-
- const features = extractFeaturesFromFilters([spatialFilter]);
- expect(features[0].geometry.coordinates[0][0]).toEqual([-89.87125, 63.35109118642093]);
- expect(features[0].properties).toEqual({
- filter: 'geo.coordinates within 1096km of -89.87125,53.49454',
- });
- });
-
- it('should convert geo_shape filter to feature', () => {
- const spatialFilter = {
- geo_shape: {
- 'geo.coordinates': {
- relation: 'INTERSECTS',
- shape: {
- coordinates: [
- [
- [-101.21639, 48.1413],
- [-101.21639, 41.84905],
- [-90.95149, 41.84905],
- [-90.95149, 48.1413],
- [-101.21639, 48.1413],
- ],
- ],
- type: 'Polygon',
- },
- },
- ignore_unmapped: true,
- },
- meta: {
- alias: 'geo.coordinates in bounds',
- disabled: false,
- index: '90943e30-9a47-11e8-b64d-95841ca0b247',
- key: 'geo.coordinates',
- negate: false,
- type: 'spatial_filter',
- value: '',
- },
- };
-
- expect(extractFeaturesFromFilters([spatialFilter])).toEqual([
- {
- type: 'Feature',
- geometry: {
- type: 'Polygon',
- coordinates: [
- [
- [-101.21639, 48.1413],
- [-101.21639, 41.84905],
- [-90.95149, 41.84905],
- [-90.95149, 48.1413],
- [-101.21639, 48.1413],
- ],
- ],
- },
- properties: {
- filter: 'geo.coordinates in bounds',
- },
- },
- ]);
- });
-
- it('should ignore geo_shape filter with pre-index shape', () => {
- const spatialFilter = {
- geo_shape: {
- 'geo.coordinates': {
- indexed_shape: {
- id: 's5gldXEBkTB2HMwpC8y0',
- index: 'world_countries_v1',
- path: 'coordinates',
- },
- relation: 'INTERSECTS',
- },
- ignore_unmapped: true,
- },
- meta: {
- alias: 'geo.coordinates in multipolygon',
- disabled: false,
- index: '90943e30-9a47-11e8-b64d-95841ca0b247',
- key: 'geo.coordinates',
- negate: false,
- type: 'spatial_filter',
- value: '',
- },
- };
-
- expect(extractFeaturesFromFilters([spatialFilter])).toEqual([]);
- });
-});
-
describe('makeESBbox', () => {
it('Should invert Y-axis', () => {
const bbox = makeESBbox({
diff --git a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts
index e47afce77f7793..8033f8d187fd51 100644
--- a/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts
+++ b/x-pack/plugins/maps/common/elasticsearch_util/elasticsearch_geo_utils.ts
@@ -16,61 +16,13 @@ import { BBox } from '@turf/helpers';
import {
DECIMAL_DEGREES_PRECISION,
ES_GEO_FIELD_TYPE,
- ES_SPATIAL_RELATIONS,
GEO_JSON_TYPE,
POLYGON_COORDINATES_EXTERIOR_INDEX,
LON_INDEX,
LAT_INDEX,
} from '../constants';
-import { getEsSpatialRelationLabel } from '../i18n_getters';
-import { Filter, FilterMeta, FILTERS } from '../../../../../src/plugins/data/common';
import { MapExtent } from '../descriptor_types';
-
-const SPATIAL_FILTER_TYPE = FILTERS.SPATIAL_FILTER;
-
-type Coordinates = Position | Position[] | Position[][] | Position[][][];
-
-// Elasticsearch stores more then just GeoJSON.
-// 1) geometry.type as lower case string
-// 2) circle and envelope types
-interface ESGeometry {
- type: string;
- coordinates: Coordinates;
-}
-
-export interface ESBBox {
- top_left: number[];
- bottom_right: number[];
-}
-
-interface GeoShapeQueryBody {
- shape?: Polygon;
- relation?: ES_SPATIAL_RELATIONS;
- indexed_shape?: PreIndexedShape;
-}
-
-// Index signature explicitly states that anything stored in an object using a string conforms to the structure
-// problem is that Elasticsearch signature also allows for other string keys to conform to other structures, like 'ignore_unmapped'
-// Use intersection type to exclude certain properties from the index signature
-// https://basarat.gitbook.io/typescript/type-system/index-signatures#excluding-certain-properties-from-the-index-signature
-type GeoShapeQuery = { ignore_unmapped: boolean } & { [geoFieldName: string]: GeoShapeQueryBody };
-
-export type GeoFilter = Filter & {
- geo_bounding_box?: {
- [geoFieldName: string]: ESBBox;
- };
- geo_distance?: {
- distance: string;
- [geoFieldName: string]: Position | { lat: number; lon: number } | string;
- };
- geo_shape?: GeoShapeQuery;
-};
-
-export interface PreIndexedShape {
- index: string;
- id: string | number;
- path: string;
-}
+import { Coordinates, ESBBox, ESGeometry } from './types';
function ensureGeoField(type: string) {
const expectedTypes = [ES_GEO_FIELD_TYPE.GEO_POINT, ES_GEO_FIELD_TYPE.GEO_SHAPE];
@@ -349,136 +301,6 @@ export function makeESBbox({ maxLat, maxLon, minLat, minLon }: MapExtent): ESBBo
return esBbox;
}
-export function createExtentFilter(mapExtent: MapExtent, geoFieldNames: string[]): GeoFilter {
- const esBbox = makeESBbox(mapExtent);
- return geoFieldNames.length === 1
- ? {
- geo_bounding_box: {
- [geoFieldNames[0]]: esBbox,
- },
- meta: {
- alias: null,
- disabled: false,
- negate: false,
- key: geoFieldNames[0],
- },
- }
- : {
- query: {
- bool: {
- should: geoFieldNames.map((geoFieldName) => {
- return {
- bool: {
- must: [
- {
- exists: {
- field: geoFieldName,
- },
- },
- {
- geo_bounding_box: {
- [geoFieldName]: esBbox,
- },
- },
- ],
- },
- };
- }),
- },
- },
- meta: {
- alias: null,
- disabled: false,
- negate: false,
- },
- };
-}
-
-export function createSpatialFilterWithGeometry({
- preIndexedShape,
- geometry,
- geometryLabel,
- indexPatternId,
- geoFieldName,
- relation = ES_SPATIAL_RELATIONS.INTERSECTS,
-}: {
- preIndexedShape?: PreIndexedShape | null;
- geometry: Polygon;
- geometryLabel: string;
- indexPatternId: string;
- geoFieldName: string;
- relation: ES_SPATIAL_RELATIONS;
-}): GeoFilter {
- const meta: FilterMeta = {
- type: SPATIAL_FILTER_TYPE,
- negate: false,
- index: indexPatternId,
- key: geoFieldName,
- alias: `${geoFieldName} ${getEsSpatialRelationLabel(relation)} ${geometryLabel}`,
- disabled: false,
- };
-
- const shapeQuery: GeoShapeQueryBody = {
- relation,
- };
- if (preIndexedShape) {
- shapeQuery.indexed_shape = preIndexedShape;
- } else {
- shapeQuery.shape = geometry;
- }
-
- return {
- meta,
- // Currently no way to create an object with exclude property from index signature
- // typescript error for "ignore_unmapped is not assignable to type 'GeoShapeQueryBody'" expected"
- // @ts-expect-error
- geo_shape: {
- ignore_unmapped: true,
- [geoFieldName]: shapeQuery,
- },
- };
-}
-
-export function createDistanceFilterWithMeta({
- alias,
- distanceKm,
- geoFieldName,
- indexPatternId,
- point,
-}: {
- alias: string;
- distanceKm: number;
- geoFieldName: string;
- indexPatternId: string;
- point: Position;
-}): GeoFilter {
- const meta: FilterMeta = {
- type: SPATIAL_FILTER_TYPE,
- negate: false,
- index: indexPatternId,
- key: geoFieldName,
- alias: alias
- ? alias
- : i18n.translate('xpack.maps.es_geo_utils.distanceFilterAlias', {
- defaultMessage: '{geoFieldName} within {distanceKm}km of {pointLabel}',
- values: {
- distanceKm,
- geoFieldName,
- pointLabel: point.join(', '),
- },
- }),
- disabled: false,
- };
-
- return {
- geo_distance: {
- distance: `${distanceKm}km`,
- [geoFieldName]: point,
- },
- meta,
- };
-}
-
export function roundCoordinates(coordinates: Coordinates): void {
for (let i = 0; i < coordinates.length; i++) {
const value = coordinates[i];
@@ -549,44 +371,6 @@ export function clamp(val: number, min: number, max: number): number {
}
}
-export function extractFeaturesFromFilters(filters: GeoFilter[]): Feature[] {
- const features: Feature[] = [];
- filters
- .filter((filter) => {
- return filter.meta.key && filter.meta.type === SPATIAL_FILTER_TYPE;
- })
- .forEach((filter) => {
- const geoFieldName = filter.meta.key!;
- let geometry;
- if (filter.geo_distance && filter.geo_distance[geoFieldName]) {
- const distanceSplit = filter.geo_distance.distance.split('km');
- const distance = parseFloat(distanceSplit[0]);
- const circleFeature = turfCircle(filter.geo_distance[geoFieldName], distance);
- geometry = circleFeature.geometry;
- } else if (
- filter.geo_shape &&
- filter.geo_shape[geoFieldName] &&
- filter.geo_shape[geoFieldName].shape
- ) {
- geometry = filter.geo_shape[geoFieldName].shape;
- } else {
- // do not know how to convert spatial filter to geometry
- // this includes pre-indexed shapes
- return;
- }
-
- features.push({
- type: 'Feature',
- geometry,
- properties: {
- filter: filter.meta.alias,
- },
- });
- });
-
- return features;
-}
-
export function scaleBounds(bounds: MapExtent, scaleFactor: number): MapExtent {
const width = bounds.maxLon - bounds.minLon;
const height = bounds.maxLat - bounds.minLat;
diff --git a/x-pack/plugins/maps/common/elasticsearch_util/index.ts b/x-pack/plugins/maps/common/elasticsearch_util/index.ts
index 24dd56b217401c..7073a4201f7a5a 100644
--- a/x-pack/plugins/maps/common/elasticsearch_util/index.ts
+++ b/x-pack/plugins/maps/common/elasticsearch_util/index.ts
@@ -8,4 +8,6 @@
export * from './es_agg_utils';
export * from './convert_to_geojson';
export * from './elasticsearch_geo_utils';
+export * from './spatial_filter_utils';
+export * from './types';
export { isTotalHitsGreaterThan, TotalHits } from './total_hits';
diff --git a/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.test.ts b/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.test.ts
new file mode 100644
index 00000000000000..d828aca4a1a001
--- /dev/null
+++ b/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.test.ts
@@ -0,0 +1,534 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { Polygon } from 'geojson';
+import {
+ createDistanceFilterWithMeta,
+ createExtentFilter,
+ createSpatialFilterWithGeometry,
+ extractFeaturesFromFilters,
+} from './spatial_filter_utils';
+
+const geoFieldName = 'location';
+
+describe('createExtentFilter', () => {
+ it('should return elasticsearch geo_bounding_box filter', () => {
+ const mapExtent = {
+ maxLat: 39,
+ maxLon: -83,
+ minLat: 35,
+ minLon: -89,
+ };
+ const filter = createExtentFilter(mapExtent, [geoFieldName]);
+ expect(filter.geo_bounding_box).toEqual({
+ location: {
+ top_left: [-89, 39],
+ bottom_right: [-83, 35],
+ },
+ });
+ });
+
+ it('should clamp longitudes to -180 to 180 and latitudes to -90 to 90', () => {
+ const mapExtent = {
+ maxLat: 120,
+ maxLon: 200,
+ minLat: -100,
+ minLon: -190,
+ };
+ const filter = createExtentFilter(mapExtent, [geoFieldName]);
+ expect(filter.geo_bounding_box).toEqual({
+ location: {
+ top_left: [-180, 89],
+ bottom_right: [180, -89],
+ },
+ });
+ });
+
+ it('should make left longitude greater than right longitude when area crosses 180 meridian east to west', () => {
+ const mapExtent = {
+ maxLat: 39,
+ maxLon: 200,
+ minLat: 35,
+ minLon: 100,
+ };
+ const filter = createExtentFilter(mapExtent, [geoFieldName]);
+ expect(filter.geo_bounding_box).toEqual({
+ location: {
+ top_left: [100, 39],
+ bottom_right: [-160, 35],
+ },
+ });
+ });
+
+ it('should make left longitude greater than right longitude when area crosses 180 meridian west to east', () => {
+ const mapExtent = {
+ maxLat: 39,
+ maxLon: -100,
+ minLat: 35,
+ minLon: -200,
+ };
+ const filter = createExtentFilter(mapExtent, [geoFieldName]);
+ expect(filter.geo_bounding_box).toEqual({
+ location: {
+ top_left: [160, 39],
+ bottom_right: [-100, 35],
+ },
+ });
+ });
+
+ it('should clamp longitudes to -180 to 180 when longitude wraps globe', () => {
+ const mapExtent = {
+ maxLat: 39,
+ maxLon: 209,
+ minLat: 35,
+ minLon: -191,
+ };
+ const filter = createExtentFilter(mapExtent, [geoFieldName]);
+ expect(filter.geo_bounding_box).toEqual({
+ location: {
+ top_left: [-180, 39],
+ bottom_right: [180, 35],
+ },
+ });
+ });
+
+ it('should support multiple geo fields', () => {
+ const mapExtent = {
+ maxLat: 39,
+ maxLon: -83,
+ minLat: 35,
+ minLon: -89,
+ };
+ expect(createExtentFilter(mapExtent, [geoFieldName, 'myOtherLocation'])).toEqual({
+ meta: {
+ alias: null,
+ disabled: false,
+ isMultiIndex: true,
+ negate: false,
+ },
+ query: {
+ bool: {
+ should: [
+ {
+ bool: {
+ must: [
+ {
+ exists: {
+ field: 'location',
+ },
+ },
+ {
+ geo_bounding_box: {
+ location: {
+ top_left: [-89, 39],
+ bottom_right: [-83, 35],
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ bool: {
+ must: [
+ {
+ exists: {
+ field: 'myOtherLocation',
+ },
+ },
+ {
+ geo_bounding_box: {
+ myOtherLocation: {
+ top_left: [-89, 39],
+ bottom_right: [-83, 35],
+ },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ });
+ });
+});
+
+describe('createSpatialFilterWithGeometry', () => {
+ it('should build filter for single field', () => {
+ const spatialFilter = createSpatialFilterWithGeometry({
+ geometry: {
+ coordinates: [
+ [
+ [-101.21639, 48.1413],
+ [-101.21639, 41.84905],
+ [-90.95149, 41.84905],
+ [-90.95149, 48.1413],
+ [-101.21639, 48.1413],
+ ],
+ ],
+ type: 'Polygon',
+ },
+ geometryLabel: 'myShape',
+ geoFieldNames: ['geo.coordinates'],
+ });
+ expect(spatialFilter).toEqual({
+ meta: {
+ alias: 'intersects myShape',
+ disabled: false,
+ key: 'geo.coordinates',
+ negate: false,
+ type: 'spatial_filter',
+ },
+ geo_shape: {
+ 'geo.coordinates': {
+ relation: 'INTERSECTS',
+ shape: {
+ coordinates: [
+ [
+ [-101.21639, 48.1413],
+ [-101.21639, 41.84905],
+ [-90.95149, 41.84905],
+ [-90.95149, 48.1413],
+ [-101.21639, 48.1413],
+ ],
+ ],
+ type: 'Polygon',
+ },
+ },
+ ignore_unmapped: true,
+ },
+ });
+ });
+
+ it('should build filter for multiple field', () => {
+ const spatialFilter = createSpatialFilterWithGeometry({
+ geometry: {
+ coordinates: [
+ [
+ [-101.21639, 48.1413],
+ [-101.21639, 41.84905],
+ [-90.95149, 41.84905],
+ [-90.95149, 48.1413],
+ [-101.21639, 48.1413],
+ ],
+ ],
+ type: 'Polygon',
+ },
+ geometryLabel: 'myShape',
+ geoFieldNames: ['geo.coordinates', 'location'],
+ });
+ expect(spatialFilter).toEqual({
+ meta: {
+ alias: 'intersects myShape',
+ disabled: false,
+ isMultiIndex: true,
+ key: undefined,
+ negate: false,
+ type: 'spatial_filter',
+ },
+ query: {
+ bool: {
+ should: [
+ {
+ bool: {
+ must: [
+ {
+ exists: {
+ field: 'geo.coordinates',
+ },
+ },
+ {
+ geo_shape: {
+ 'geo.coordinates': {
+ relation: 'INTERSECTS',
+ shape: {
+ coordinates: [
+ [
+ [-101.21639, 48.1413],
+ [-101.21639, 41.84905],
+ [-90.95149, 41.84905],
+ [-90.95149, 48.1413],
+ [-101.21639, 48.1413],
+ ],
+ ],
+ type: 'Polygon',
+ },
+ },
+ ignore_unmapped: true,
+ },
+ },
+ ],
+ },
+ },
+ {
+ bool: {
+ must: [
+ {
+ exists: {
+ field: 'location',
+ },
+ },
+ {
+ geo_shape: {
+ location: {
+ relation: 'INTERSECTS',
+ shape: {
+ coordinates: [
+ [
+ [-101.21639, 48.1413],
+ [-101.21639, 41.84905],
+ [-90.95149, 41.84905],
+ [-90.95149, 48.1413],
+ [-101.21639, 48.1413],
+ ],
+ ],
+ type: 'Polygon',
+ },
+ },
+ ignore_unmapped: true,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ });
+ });
+});
+
+describe('createDistanceFilterWithMeta', () => {
+ it('should build filter for single field', () => {
+ const spatialFilter = createDistanceFilterWithMeta({
+ point: [120, 30],
+ distanceKm: 1000,
+ geoFieldNames: ['geo.coordinates'],
+ });
+ expect(spatialFilter).toEqual({
+ meta: {
+ alias: 'within 1000km of 120, 30',
+ disabled: false,
+ key: 'geo.coordinates',
+ negate: false,
+ type: 'spatial_filter',
+ },
+ geo_distance: {
+ distance: '1000km',
+ 'geo.coordinates': [120, 30],
+ },
+ });
+ });
+
+ it('should build filter for multiple field', () => {
+ const spatialFilter = createDistanceFilterWithMeta({
+ point: [120, 30],
+ distanceKm: 1000,
+ geoFieldNames: ['geo.coordinates', 'location'],
+ });
+ expect(spatialFilter).toEqual({
+ meta: {
+ alias: 'within 1000km of 120, 30',
+ disabled: false,
+ isMultiIndex: true,
+ key: undefined,
+ negate: false,
+ type: 'spatial_filter',
+ },
+ query: {
+ bool: {
+ should: [
+ {
+ bool: {
+ must: [
+ {
+ exists: {
+ field: 'geo.coordinates',
+ },
+ },
+ {
+ geo_distance: {
+ distance: '1000km',
+ 'geo.coordinates': [120, 30],
+ },
+ },
+ ],
+ },
+ },
+ {
+ bool: {
+ must: [
+ {
+ exists: {
+ field: 'location',
+ },
+ },
+ {
+ geo_distance: {
+ distance: '1000km',
+ location: [120, 30],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ });
+ });
+});
+
+describe('extractFeaturesFromFilters', () => {
+ it('should ignore non-spatial filers', () => {
+ const phraseFilter = {
+ meta: {
+ alias: null,
+ disabled: false,
+ index: '90943e30-9a47-11e8-b64d-95841ca0b247',
+ key: 'machine.os',
+ negate: false,
+ params: {
+ query: 'ios',
+ },
+ type: 'phrase',
+ },
+ query: {
+ match_phrase: {
+ 'machine.os': 'ios',
+ },
+ },
+ };
+ expect(extractFeaturesFromFilters([phraseFilter])).toEqual([]);
+ });
+
+ it('should convert single field geo_distance filter to feature', () => {
+ const spatialFilter = createDistanceFilterWithMeta({
+ point: [-89.87125, 53.49454],
+ distanceKm: 1096,
+ geoFieldNames: ['geo.coordinates', 'location'],
+ });
+
+ const features = extractFeaturesFromFilters([spatialFilter]);
+ expect((features[0].geometry as Polygon).coordinates[0][0]).toEqual([
+ -89.87125,
+ 63.35109118642093,
+ ]);
+ expect(features[0].properties).toEqual({
+ filter: 'within 1096km of -89.87125, 53.49454',
+ });
+ });
+
+ it('should convert multi field geo_distance filter to feature', () => {
+ const spatialFilter = createDistanceFilterWithMeta({
+ point: [-89.87125, 53.49454],
+ distanceKm: 1096,
+ geoFieldNames: ['geo.coordinates', 'location'],
+ });
+
+ const features = extractFeaturesFromFilters([spatialFilter]);
+ expect((features[0].geometry as Polygon).coordinates[0][0]).toEqual([
+ -89.87125,
+ 63.35109118642093,
+ ]);
+ expect(features[0].properties).toEqual({
+ filter: 'within 1096km of -89.87125, 53.49454',
+ });
+ });
+
+ it('should convert single field geo_shape filter to feature', () => {
+ const spatialFilter = createSpatialFilterWithGeometry({
+ geometry: {
+ coordinates: [
+ [
+ [-101.21639, 48.1413],
+ [-101.21639, 41.84905],
+ [-90.95149, 41.84905],
+ [-90.95149, 48.1413],
+ [-101.21639, 48.1413],
+ ],
+ ],
+ type: 'Polygon',
+ },
+ geometryLabel: 'myShape',
+ geoFieldNames: ['geo.coordinates'],
+ });
+ expect(extractFeaturesFromFilters([spatialFilter])).toEqual([
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-101.21639, 48.1413],
+ [-101.21639, 41.84905],
+ [-90.95149, 41.84905],
+ [-90.95149, 48.1413],
+ [-101.21639, 48.1413],
+ ],
+ ],
+ } as Polygon,
+ properties: {
+ filter: 'intersects myShape',
+ },
+ },
+ ]);
+ });
+
+ it('should convert multi field geo_shape filter to feature', () => {
+ const spatialFilter = createSpatialFilterWithGeometry({
+ geometry: {
+ coordinates: [
+ [
+ [-101.21639, 48.1413],
+ [-101.21639, 41.84905],
+ [-90.95149, 41.84905],
+ [-90.95149, 48.1413],
+ [-101.21639, 48.1413],
+ ],
+ ],
+ type: 'Polygon',
+ },
+ geometryLabel: 'myShape',
+ geoFieldNames: ['geo.coordinates', 'location'],
+ });
+ expect(extractFeaturesFromFilters([spatialFilter])).toEqual([
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [
+ [
+ [-101.21639, 48.1413],
+ [-101.21639, 41.84905],
+ [-90.95149, 41.84905],
+ [-90.95149, 48.1413],
+ [-101.21639, 48.1413],
+ ],
+ ],
+ } as Polygon,
+ properties: {
+ filter: 'intersects myShape',
+ },
+ },
+ ]);
+ });
+
+ it('should ignore geo_shape filter with pre-index shape', () => {
+ const spatialFilter = createSpatialFilterWithGeometry({
+ preIndexedShape: {
+ index: 'world_countries_v1',
+ id: 's5gldXEBkTB2HMwpC8y0',
+ path: 'coordinates',
+ },
+ geometryLabel: 'myShape',
+ geoFieldNames: ['geo.coordinates'],
+ });
+ expect(extractFeaturesFromFilters([spatialFilter])).toEqual([]);
+ });
+});
diff --git a/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.ts b/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.ts
new file mode 100644
index 00000000000000..70df9e9646f50f
--- /dev/null
+++ b/x-pack/plugins/maps/common/elasticsearch_util/spatial_filter_utils.ts
@@ -0,0 +1,221 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { Feature, Geometry, Polygon, Position } from 'geojson';
+// @ts-expect-error
+import turfCircle from '@turf/circle';
+import { FilterMeta, FILTERS } from '../../../../../src/plugins/data/common';
+import { MapExtent } from '../descriptor_types';
+import { ES_SPATIAL_RELATIONS } from '../constants';
+import { getEsSpatialRelationLabel } from '../i18n_getters';
+import { GeoFilter, GeoShapeQueryBody, PreIndexedShape } from './types';
+import { makeESBbox } from './elasticsearch_geo_utils';
+
+const SPATIAL_FILTER_TYPE = FILTERS.SPATIAL_FILTER;
+
+// wrapper around boiler plate code for creating bool.should clause with nested bool.must clauses
+// ensuring geoField exists prior to running geoField query
+// This allows for writing a single geo filter that spans multiple indices with different geo fields.
+function createMultiGeoFieldFilter(
+ geoFieldNames: string[],
+ meta: FilterMeta,
+ createGeoFilter: (geoFieldName: string) => Omit
+): GeoFilter {
+ if (geoFieldNames.length === 0) {
+ throw new Error('Unable to create filter, geo fields not provided');
+ }
+
+ if (geoFieldNames.length === 1) {
+ const geoFilter = createGeoFilter(geoFieldNames[0]);
+ return {
+ meta: {
+ ...meta,
+ key: geoFieldNames[0],
+ },
+ ...geoFilter,
+ };
+ }
+
+ return {
+ meta: {
+ ...meta,
+ key: undefined,
+ isMultiIndex: true,
+ },
+ query: {
+ bool: {
+ should: geoFieldNames.map((geoFieldName) => {
+ return {
+ bool: {
+ must: [
+ {
+ exists: {
+ field: geoFieldName,
+ },
+ },
+ createGeoFilter(geoFieldName),
+ ],
+ },
+ };
+ }),
+ },
+ },
+ };
+}
+
+export function createExtentFilter(mapExtent: MapExtent, geoFieldNames: string[]): GeoFilter {
+ const esBbox = makeESBbox(mapExtent);
+ function createGeoFilter(geoFieldName: string) {
+ return {
+ geo_bounding_box: {
+ [geoFieldName]: esBbox,
+ },
+ };
+ }
+
+ const meta: FilterMeta = {
+ alias: null,
+ disabled: false,
+ negate: false,
+ };
+
+ return createMultiGeoFieldFilter(geoFieldNames, meta, createGeoFilter);
+}
+
+export function createSpatialFilterWithGeometry({
+ preIndexedShape,
+ geometry,
+ geometryLabel,
+ geoFieldNames,
+ relation = ES_SPATIAL_RELATIONS.INTERSECTS,
+}: {
+ preIndexedShape?: PreIndexedShape | null;
+ geometry?: Polygon;
+ geometryLabel: string;
+ geoFieldNames: string[];
+ relation?: ES_SPATIAL_RELATIONS;
+}): GeoFilter {
+ const meta: FilterMeta = {
+ type: SPATIAL_FILTER_TYPE,
+ negate: false,
+ key: geoFieldNames.length === 1 ? geoFieldNames[0] : undefined,
+ alias: `${getEsSpatialRelationLabel(relation)} ${geometryLabel}`,
+ disabled: false,
+ };
+
+ function createGeoFilter(geoFieldName: string) {
+ const shapeQuery: GeoShapeQueryBody = {
+ relation,
+ };
+ if (preIndexedShape) {
+ shapeQuery.indexed_shape = preIndexedShape;
+ } else if (geometry) {
+ shapeQuery.shape = geometry;
+ } else {
+ throw new Error('Must supply either preIndexedShape or geometry, you did not supply either');
+ }
+
+ return {
+ geo_shape: {
+ ignore_unmapped: true,
+ [geoFieldName]: shapeQuery,
+ },
+ };
+ }
+
+ // Currently no way to create an object with exclude property from index signature
+ // typescript error for "ignore_unmapped is not assignable to type 'GeoShapeQueryBody'" expected"
+ // @ts-expect-error
+ return createMultiGeoFieldFilter(geoFieldNames, meta, createGeoFilter);
+}
+
+export function createDistanceFilterWithMeta({
+ alias,
+ distanceKm,
+ geoFieldNames,
+ point,
+}: {
+ alias?: string;
+ distanceKm: number;
+ geoFieldNames: string[];
+ point: Position;
+}): GeoFilter {
+ const meta: FilterMeta = {
+ type: SPATIAL_FILTER_TYPE,
+ negate: false,
+ alias: alias
+ ? alias
+ : i18n.translate('xpack.maps.es_geo_utils.distanceFilterAlias', {
+ defaultMessage: 'within {distanceKm}km of {pointLabel}',
+ values: {
+ distanceKm,
+ pointLabel: point.join(', '),
+ },
+ }),
+ disabled: false,
+ };
+
+ function createGeoFilter(geoFieldName: string) {
+ return {
+ geo_distance: {
+ distance: `${distanceKm}km`,
+ [geoFieldName]: point,
+ },
+ };
+ }
+
+ return createMultiGeoFieldFilter(geoFieldNames, meta, createGeoFilter);
+}
+
+function extractGeometryFromFilter(geoFieldName: string, filter: GeoFilter): Geometry | undefined {
+ if (filter.geo_distance && filter.geo_distance[geoFieldName]) {
+ const distanceSplit = filter.geo_distance.distance.split('km');
+ const distance = parseFloat(distanceSplit[0]);
+ const circleFeature = turfCircle(filter.geo_distance[geoFieldName], distance);
+ return circleFeature.geometry;
+ }
+
+ if (filter.geo_shape && filter.geo_shape[geoFieldName] && filter.geo_shape[geoFieldName].shape) {
+ return filter.geo_shape[geoFieldName].shape;
+ }
+}
+
+export function extractFeaturesFromFilters(filters: GeoFilter[]): Feature[] {
+ const features: Feature[] = [];
+ filters
+ .filter((filter) => {
+ return filter.meta.type === SPATIAL_FILTER_TYPE;
+ })
+ .forEach((filter) => {
+ let geometry: Geometry | undefined;
+ if (filter.meta.isMultiIndex) {
+ const geoFieldName = filter?.query?.bool?.should?.[0]?.bool?.must?.[0]?.exists?.field;
+ const spatialClause = filter?.query?.bool?.should?.[0]?.bool?.must?.[1];
+ if (geoFieldName && spatialClause) {
+ geometry = extractGeometryFromFilter(geoFieldName, spatialClause);
+ }
+ } else {
+ const geoFieldName = filter.meta.key;
+ if (geoFieldName) {
+ geometry = extractGeometryFromFilter(geoFieldName, filter);
+ }
+ }
+
+ if (geometry) {
+ features.push({
+ type: 'Feature',
+ geometry,
+ properties: {
+ filter: filter.meta.alias,
+ },
+ });
+ }
+ });
+
+ return features;
+}
diff --git a/x-pack/plugins/maps/common/elasticsearch_util/types.ts b/x-pack/plugins/maps/common/elasticsearch_util/types.ts
new file mode 100644
index 00000000000000..bbb508ce69275d
--- /dev/null
+++ b/x-pack/plugins/maps/common/elasticsearch_util/types.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { Polygon, Position } from 'geojson';
+import { Filter } from '../../../../../src/plugins/data/common';
+import { ES_SPATIAL_RELATIONS } from '../constants';
+
+export type Coordinates = Position | Position[] | Position[][] | Position[][][];
+
+// Elasticsearch stores more then just GeoJSON.
+// 1) geometry.type as lower case string
+// 2) circle and envelope types
+export interface ESGeometry {
+ type: string;
+ coordinates: Coordinates;
+}
+
+export interface ESBBox {
+ top_left: number[];
+ bottom_right: number[];
+}
+
+export interface GeoShapeQueryBody {
+ shape?: Polygon;
+ relation?: ES_SPATIAL_RELATIONS;
+ indexed_shape?: PreIndexedShape;
+}
+
+// Index signature explicitly states that anything stored in an object using a string conforms to the structure
+// problem is that Elasticsearch signature also allows for other string keys to conform to other structures, like 'ignore_unmapped'
+// Use intersection type to exclude certain properties from the index signature
+// https://basarat.gitbook.io/typescript/type-system/index-signatures#excluding-certain-properties-from-the-index-signature
+type GeoShapeQuery = { ignore_unmapped: boolean } & { [geoFieldName: string]: GeoShapeQueryBody };
+
+export type GeoFilter = Filter & {
+ geo_bounding_box?: {
+ [geoFieldName: string]: ESBBox;
+ };
+ geo_distance?: {
+ distance: string;
+ [geoFieldName: string]: Position | { lat: number; lon: number } | string;
+ };
+ geo_shape?: GeoShapeQuery;
+};
+
+export interface PreIndexedShape {
+ index: string;
+ id: string | number;
+ path: string;
+}
diff --git a/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap b/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap
index ccbe4667b78ea4..80238bf3a4d17f 100644
--- a/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap
+++ b/x-pack/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`should not show "within" relation when filter geometry is not closed 1`] = `
+exports[`render 1`] = `
-
-
-
-
-
-
- Create filter
-
-
-
-`;
-
-exports[`should render error message 1`] = `
-
-
-
-
-
-
-
-
- Simulated error
-
@@ -177,7 +70,7 @@ exports[`should render error message 1`] = `
`;
-exports[`should render relation select when geo field is geo_shape 1`] = `
+exports[`should render error message 1`] = `
-
-
-
- Create filter
-
-
-
-`;
-
-exports[`should render relation select without "within"-relation when geo field is geo_point 1`] = `
-
-
-
-
-
-
-
-
-
-
+
+ Simulated error
+
diff --git a/x-pack/plugins/maps/public/components/distance_filter_form.tsx b/x-pack/plugins/maps/public/components/distance_filter_form.tsx
index 14ae6b11b85c8b..b5fdcbc46b932c 100644
--- a/x-pack/plugins/maps/public/components/distance_filter_form.tsx
+++ b/x-pack/plugins/maps/public/components/distance_filter_form.tsx
@@ -16,47 +16,28 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public';
-import { MultiIndexGeoFieldSelect } from './multi_index_geo_field_select';
-import { GeoFieldWithIndex } from './geo_field_with_index';
import { ActionSelect } from './action_select';
import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../src/plugins/data/public';
interface Props {
className?: string;
buttonLabel: string;
- geoFields: GeoFieldWithIndex[];
getFilterActions?: () => Promise;
getActionContext?: () => ActionExecutionContext;
- onSubmit: ({
- actionId,
- filterLabel,
- indexPatternId,
- geoFieldName,
- }: {
- actionId: string;
- filterLabel: string;
- indexPatternId: string;
- geoFieldName: string;
- }) => void;
+ onSubmit: ({ actionId, filterLabel }: { actionId: string; filterLabel: string }) => void;
}
interface State {
actionId: string;
- selectedField: GeoFieldWithIndex | undefined;
filterLabel: string;
}
export class DistanceFilterForm extends Component {
state: State = {
actionId: ACTION_GLOBAL_APPLY_FILTER,
- selectedField: this.props.geoFields.length ? this.props.geoFields[0] : undefined,
filterLabel: '',
};
- _onGeoFieldChange = (selectedField: GeoFieldWithIndex | undefined) => {
- this.setState({ selectedField });
- };
-
_onFilterLabelChange = (e: ChangeEvent) => {
this.setState({
filterLabel: e.target.value,
@@ -68,14 +49,9 @@ export class DistanceFilterForm extends Component {
};
_onSubmit = () => {
- if (!this.state.selectedField) {
- return;
- }
this.props.onSubmit({
actionId: this.state.actionId,
filterLabel: this.state.filterLabel,
- indexPatternId: this.state.selectedField.indexPatternId,
- geoFieldName: this.state.selectedField.geoFieldName,
});
};
@@ -95,12 +71,6 @@ export class DistanceFilterForm extends Component {
/>
-
-
{
-
+
{this.props.buttonLabel}
diff --git a/x-pack/plugins/maps/public/components/geo_field_with_index.ts b/x-pack/plugins/maps/public/components/geo_field_with_index.ts
deleted file mode 100644
index 5273bff44f8d7e..00000000000000
--- a/x-pack/plugins/maps/public/components/geo_field_with_index.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-/* eslint-disable @typescript-eslint/consistent-type-definitions */
-
-// Maps can contain geo fields from multiple index patterns. GeoFieldWithIndex is used to:
-// 1) Combine the geo field along with associated index pattern state.
-// 2) Package asynchronously looked up state via getIndexPatternService() to avoid
-// PITA of looking up async state in downstream react consumers.
-export type GeoFieldWithIndex = {
- geoFieldName: string;
- geoFieldType: string;
- indexPatternTitle: string;
- indexPatternId: string;
-};
diff --git a/x-pack/plugins/maps/public/components/geometry_filter_form.js b/x-pack/plugins/maps/public/components/geometry_filter_form.js
index 624d3b60fe14b8..2e13f63b798836 100644
--- a/x-pack/plugins/maps/public/components/geometry_filter_form.js
+++ b/x-pack/plugins/maps/public/components/geometry_filter_form.js
@@ -18,16 +18,14 @@ import {
EuiFormErrorText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { ES_GEO_FIELD_TYPE, ES_SPATIAL_RELATIONS } from '../../common/constants';
+import { ES_SPATIAL_RELATIONS } from '../../common/constants';
import { getEsSpatialRelationLabel } from '../../common/i18n_getters';
-import { MultiIndexGeoFieldSelect } from './multi_index_geo_field_select';
import { ActionSelect } from './action_select';
import { ACTION_GLOBAL_APPLY_FILTER } from '../../../../../src/plugins/data/public';
export class GeometryFilterForm extends Component {
static propTypes = {
buttonLabel: PropTypes.string.isRequired,
- geoFields: PropTypes.array.isRequired,
getFilterActions: PropTypes.func,
getActionContext: PropTypes.func,
intitialGeometryLabel: PropTypes.string.isRequired,
@@ -42,15 +40,10 @@ export class GeometryFilterForm extends Component {
state = {
actionId: ACTION_GLOBAL_APPLY_FILTER,
- selectedField: this.props.geoFields.length ? this.props.geoFields[0] : undefined,
geometryLabel: this.props.intitialGeometryLabel,
relation: ES_SPATIAL_RELATIONS.INTERSECTS,
};
- _onGeoFieldChange = (selectedField) => {
- this.setState({ selectedField });
- };
-
_onGeometryLabelChange = (e) => {
this.setState({
geometryLabel: e.target.value,
@@ -71,29 +64,12 @@ export class GeometryFilterForm extends Component {
this.props.onSubmit({
actionId: this.state.actionId,
geometryLabel: this.state.geometryLabel,
- indexPatternId: this.state.selectedField.indexPatternId,
- geoFieldName: this.state.selectedField.geoFieldName,
relation: this.state.relation,
});
};
_renderRelationInput() {
- // relationship only used when filtering geo_shape fields
- if (!this.state.selectedField) {
- return null;
- }
-
- const spatialRelations =
- this.props.isFilterGeometryClosed &&
- this.state.selectedField.geoFieldType !== ES_GEO_FIELD_TYPE.GEO_POINT
- ? Object.values(ES_SPATIAL_RELATIONS)
- : Object.values(ES_SPATIAL_RELATIONS).filter((relation) => {
- // - cannot filter by "within"-relation when filtering geometry is not closed
- // - do not distinguish between intersects/within for filtering for points since they are equivalent
- return relation !== ES_SPATIAL_RELATIONS.WITHIN;
- });
-
- const options = spatialRelations.map((relation) => {
+ const options = Object.values(ES_SPATIAL_RELATIONS).map((relation) => {
return {
value: relation,
text: getEsSpatialRelationLabel(relation),
@@ -137,12 +113,6 @@ export class GeometryFilterForm extends Component {
/>
-
-
{this._renderRelationInput()}
{this.props.buttonLabel}
diff --git a/x-pack/plugins/maps/public/components/geometry_filter_form.test.js b/x-pack/plugins/maps/public/components/geometry_filter_form.test.js
index d981caf944ab97..3ce79782788b0d 100644
--- a/x-pack/plugins/maps/public/components/geometry_filter_form.test.js
+++ b/x-pack/plugins/maps/public/components/geometry_filter_form.test.js
@@ -16,76 +16,14 @@ const defaultProps = {
onSubmit: () => {},
};
-test('should render relation select without "within"-relation when geo field is geo_point', async () => {
- const component = shallow(
-
- );
-
- expect(component).toMatchSnapshot();
-});
-
-test('should render relation select when geo field is geo_shape', async () => {
- const component = shallow(
-
- );
-
- expect(component).toMatchSnapshot();
-});
-
-test('should not show "within" relation when filter geometry is not closed', async () => {
- const component = shallow(
-
- );
+test('render', async () => {
+ const component = shallow();
expect(component).toMatchSnapshot();
});
test('should render error message', async () => {
- const component = shallow(
-
- );
+ const component = shallow();
expect(component).toMatchSnapshot();
});
diff --git a/x-pack/plugins/maps/public/components/multi_index_geo_field_select.tsx b/x-pack/plugins/maps/public/components/multi_index_geo_field_select.tsx
deleted file mode 100644
index 564b84ae84300f..00000000000000
--- a/x-pack/plugins/maps/public/components/multi_index_geo_field_select.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import React from 'react';
-import { EuiFormRow, EuiSuperSelect, EuiTextColor, EuiText } from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import { GeoFieldWithIndex } from './geo_field_with_index';
-
-const OPTION_ID_DELIMITER = '/';
-
-function createOptionId(geoField: GeoFieldWithIndex): string {
- // Namespace field with indexPatterId to avoid collisions between field names
- return `${geoField.indexPatternId}${OPTION_ID_DELIMITER}${geoField.geoFieldName}`;
-}
-
-function splitOptionId(optionId: string) {
- const split = optionId.split(OPTION_ID_DELIMITER);
- return {
- indexPatternId: split[0],
- geoFieldName: split[1],
- };
-}
-
-interface Props {
- fields: GeoFieldWithIndex[];
- onChange: (newSelectedField: GeoFieldWithIndex | undefined) => void;
- selectedField: GeoFieldWithIndex | undefined;
-}
-
-export function MultiIndexGeoFieldSelect({ fields, onChange, selectedField }: Props) {
- function onFieldSelect(selectedOptionId: string) {
- const { indexPatternId, geoFieldName } = splitOptionId(selectedOptionId);
-
- const newSelectedField = fields.find((field) => {
- return field.indexPatternId === indexPatternId && field.geoFieldName === geoFieldName;
- });
- onChange(newSelectedField);
- }
-
- const options = fields.map((geoField: GeoFieldWithIndex) => {
- return {
- inputDisplay: (
-
-
- {geoField.indexPatternTitle}
-
-
- {geoField.geoFieldName}
-
- ),
- value: createOptionId(geoField),
- };
- });
-
- return (
-
-
-
- );
-}
diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx
index 02374932a4c703..26746d9ad24167 100644
--- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx
+++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx
@@ -20,15 +20,12 @@ import { ToolbarOverlay } from '../toolbar_overlay';
import { EditLayerPanel } from '../edit_layer_panel';
import { AddLayerPanel } from '../add_layer_panel';
import { ExitFullScreenButton } from '../../../../../../src/plugins/kibana_react/public';
-import { getIndexPatternsFromIds } from '../../index_pattern_util';
-import { ES_GEO_FIELD_TYPE, RawValue } from '../../../common/constants';
-import { indexPatterns as indexPatternsUtils } from '../../../../../../src/plugins/data/public';
+import { RawValue } from '../../../common/constants';
import { FLYOUT_STATE } from '../../reducers/ui';
import { MapSettings } from '../../reducers/map';
import { MapSettingsPanel } from '../map_settings_panel';
import { registerLayerWizards } from '../../classes/layers/load_layer_wizards';
import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property';
-import { GeoFieldWithIndex } from '../../components/geo_field_with_index';
import { MapRefreshConfig } from '../../../common/descriptor_types';
import { ILayer } from '../../classes/layers/layer';
@@ -58,7 +55,6 @@ export interface Props {
interface State {
isInitialLoadRenderTimeoutComplete: boolean;
domId: string;
- geoFields: GeoFieldWithIndex[];
showFitToBoundsButton: boolean;
showTimesliderButton: boolean;
}
@@ -66,7 +62,6 @@ interface State {
export class MapContainer extends Component {
private _isMounted: boolean = false;
private _isInitalLoadRenderTimerStarted: boolean = false;
- private _prevIndexPatternIds: string[] = [];
private _refreshTimerId: number | null = null;
private _prevIsPaused: boolean | null = null;
private _prevInterval: number | null = null;
@@ -74,7 +69,6 @@ export class MapContainer extends Component {
state: State = {
isInitialLoadRenderTimeoutComplete: false,
domId: uuid(),
- geoFields: [],
showFitToBoundsButton: false,
showTimesliderButton: false,
};
@@ -95,10 +89,6 @@ export class MapContainer extends Component {
this._isInitalLoadRenderTimerStarted = true;
this._startInitialLoadRenderTimer();
}
-
- if (!!this.props.addFilters) {
- this._loadGeoFields(this.props.indexPatternIds);
- }
}
componentWillUnmount() {
@@ -151,40 +141,6 @@ export class MapContainer extends Component {
}
}
- async _loadGeoFields(nextIndexPatternIds: string[]) {
- if (_.isEqual(nextIndexPatternIds, this._prevIndexPatternIds)) {
- // all ready loaded index pattern ids
- return;
- }
-
- this._prevIndexPatternIds = nextIndexPatternIds;
-
- const geoFields: GeoFieldWithIndex[] = [];
- const indexPatterns = await getIndexPatternsFromIds(nextIndexPatternIds);
- indexPatterns.forEach((indexPattern) => {
- indexPattern.fields.forEach((field) => {
- if (
- indexPattern.id &&
- !indexPatternsUtils.isNestedField(field) &&
- (field.type === ES_GEO_FIELD_TYPE.GEO_POINT || field.type === ES_GEO_FIELD_TYPE.GEO_SHAPE)
- ) {
- geoFields.push({
- geoFieldName: field.name,
- geoFieldType: field.type,
- indexPatternTitle: indexPattern.title,
- indexPatternId: indexPattern.id,
- });
- }
- });
- });
-
- if (!this._isMounted) {
- return;
- }
-
- this.setState({ geoFields });
- }
-
_setRefreshTimer = () => {
const { isPaused, interval } = this.props.refreshConfig;
@@ -289,13 +245,11 @@ export class MapContainer extends Component {
getFilterActions={getFilterActions}
getActionContext={getActionContext}
onSingleValueTrigger={onSingleValueTrigger}
- geoFields={this.state.geoFields}
renderTooltipContent={renderTooltipContent}
/>
{!this.props.settings.hideToolbarOverlay && (
{
_onDraw = async (e: { features: Feature[] }) => {
- if (
- !e.features.length ||
- !this.props.drawState ||
- !this.props.drawState.geoFieldName ||
- !this.props.drawState.indexPatternId
- ) {
+ if (!e.features.length || !this.props.drawState || !this.props.geoFieldNames.length) {
return;
}
@@ -61,8 +57,7 @@ export class DrawFilterControl extends Component {
filter = createDistanceFilterWithMeta({
alias: this.props.drawState.filterLabel ? this.props.drawState.filterLabel : '',
distanceKm,
- geoFieldName: this.props.drawState.geoFieldName,
- indexPatternId: this.props.drawState.indexPatternId,
+ geoFieldNames: this.props.geoFieldNames,
point: [
_.round(circle.properties.center[0], precision),
_.round(circle.properties.center[1], precision),
@@ -78,8 +73,7 @@ export class DrawFilterControl extends Component {
this.props.drawState.drawType === DRAW_TYPE.BOUNDS
? getBoundingBoxGeometry(geometry)
: geometry,
- indexPatternId: this.props.drawState.indexPatternId,
- geoFieldName: this.props.drawState.geoFieldName,
+ geoFieldNames: this.props.geoFieldNames,
geometryLabel: this.props.drawState.geometryLabel ? this.props.drawState.geometryLabel : '',
relation: this.props.drawState.relation
? this.props.drawState.relation
diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/index.ts b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/index.ts
index 17f4d919fb7e0d..58ad5300935577 100644
--- a/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/index.ts
+++ b/x-pack/plugins/maps/public/connected_components/mb_map/draw_control/draw_filter_control/index.ts
@@ -10,13 +10,18 @@ import { ThunkDispatch } from 'redux-thunk';
import { connect } from 'react-redux';
import { DrawFilterControl } from './draw_filter_control';
import { updateDrawState } from '../../../../actions';
-import { getDrawState, isDrawingFilter } from '../../../../selectors/map_selectors';
+import {
+ getDrawState,
+ isDrawingFilter,
+ getGeoFieldNames,
+} from '../../../../selectors/map_selectors';
import { MapStoreState } from '../../../../reducers/store';
function mapStateToProps(state: MapStoreState) {
return {
isDrawingFilter: isDrawingFilter(state),
drawState: getDrawState(state),
+ geoFieldNames: getGeoFieldNames(state),
};
}
diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx
index 877de10e113834..9d8bce083c2927 100644
--- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx
+++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx
@@ -43,7 +43,6 @@ import {
// @ts-expect-error
} from './utils';
import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public';
-import { GeoFieldWithIndex } from '../../components/geo_field_with_index';
import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property';
import { MapExtentState } from '../../actions';
import { TileStatusTracker } from './tile_status_tracker';
@@ -68,7 +67,6 @@ export interface Props {
getFilterActions?: () => Promise;
getActionContext?: () => ActionExecutionContext;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
- geoFields: GeoFieldWithIndex[];
renderTooltipContent?: RenderToolTipContent;
setAreTilesLoaded: (layerId: string, areTilesLoaded: boolean) => void;
}
@@ -432,7 +430,6 @@ export class MBMap extends Component {
getFilterActions={this.props.getFilterActions}
getActionContext={this.props.getActionContext}
onSingleValueTrigger={this.props.onSingleValueTrigger}
- geoFields={this.props.geoFields}
renderTooltipContent={this.props.renderTooltipContent}
/>
) : null;
diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/features_tooltip/feature_geometry_filter_form.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/features_tooltip/feature_geometry_filter_form.tsx
index 04e564943fa39f..5f26715c86004e 100644
--- a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/features_tooltip/feature_geometry_filter_form.tsx
+++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/features_tooltip/feature_geometry_filter_form.tsx
@@ -21,7 +21,6 @@ import {
import { ES_SPATIAL_RELATIONS, GEO_JSON_TYPE } from '../../../../../common/constants';
// @ts-expect-error
import { GeometryFilterForm } from '../../../../components/geometry_filter_form';
-import { GeoFieldWithIndex } from '../../../../components/geo_field_with_index';
// over estimated and imprecise value to ensure filter has additional room for any meta keys added when filter is mapped.
const META_OVERHEAD = 100;
@@ -29,11 +28,11 @@ const META_OVERHEAD = 100;
interface Props {
onClose: () => void;
geometry: Geometry;
- geoFields: GeoFieldWithIndex[];
addFilters: (filters: Filter[], actionId: string) => Promise;
getFilterActions?: () => Promise;
getActionContext?: () => ActionExecutionContext;
loadPreIndexedShape: () => Promise;
+ geoFieldNames: string[];
}
interface State {
@@ -77,13 +76,9 @@ export class FeatureGeometryFilterForm extends Component {
_createFilter = async ({
geometryLabel,
- indexPatternId,
- geoFieldName,
relation,
}: {
geometryLabel: string;
- indexPatternId: string;
- geoFieldName: string;
relation: ES_SPATIAL_RELATIONS;
}) => {
this.setState({ errorMsg: undefined });
@@ -97,8 +92,7 @@ export class FeatureGeometryFilterForm extends Component {
preIndexedShape,
geometry: this.props.geometry as Polygon,
geometryLabel,
- indexPatternId,
- geoFieldName,
+ geoFieldNames: this.props.geoFieldNames,
relation,
});
@@ -130,7 +124,6 @@ export class FeatureGeometryFilterForm extends Component {
defaultMessage: 'Create filter',
}
)}
- geoFields={this.props.geoFields}
getFilterActions={this.props.getFilterActions}
getActionContext={this.props.getActionContext}
intitialGeometryLabel={this.props.geometry.type.toLowerCase()}
diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/index.ts b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/index.ts
index 28510815d1a2ef..2861c803065268 100644
--- a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/index.ts
+++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/index.ts
@@ -20,6 +20,7 @@ import {
getLayerList,
getOpenTooltips,
getHasLockedTooltips,
+ getGeoFieldNames,
isDrawingFilter,
} from '../../../selectors/map_selectors';
import { MapStoreState } from '../../../reducers/store';
@@ -30,6 +31,7 @@ function mapStateToProps(state: MapStoreState) {
hasLockedTooltips: getHasLockedTooltips(state),
isDrawingFilter: isDrawingFilter(state),
openTooltips: getOpenTooltips(state),
+ geoFieldNames: getGeoFieldNames(state),
};
}
diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.test.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.test.tsx
index ac6e3cfcccf4e6..a11000a48866fc 100644
--- a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.test.tsx
+++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.test.tsx
@@ -77,7 +77,7 @@ const defaultProps = {
layerList: [mockLayer],
isDrawingFilter: false,
addFilters: async () => {},
- geoFields: [],
+ geoFieldNames: [],
openTooltips: [],
hasLockedTooltips: false,
};
diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.tsx
index 09dd9ee4f51d90..e9af9dcc89e078 100644
--- a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.tsx
+++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.tsx
@@ -20,7 +20,6 @@ import { Geometry } from 'geojson';
import { Filter } from 'src/plugins/data/public';
import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public';
import {
- ES_GEO_FIELD_TYPE,
FEATURE_ID_PROPERTY_NAME,
GEO_JSON_TYPE,
LON_INDEX,
@@ -37,7 +36,6 @@ import { FeatureGeometryFilterForm } from './features_tooltip';
import { EXCLUDE_TOO_MANY_FEATURES_BOX } from '../../../classes/util/mb_filter_expressions';
import { ILayer } from '../../../classes/layers/layer';
import { IVectorLayer } from '../../../classes/layers/vector_layer';
-import { GeoFieldWithIndex } from '../../../components/geo_field_with_index';
import { RenderToolTipContent } from '../../../classes/tooltips/tooltip_property';
function justifyAnchorLocation(
@@ -70,7 +68,7 @@ export interface Props {
closeOnHoverTooltip: () => void;
getActionContext?: () => ActionExecutionContext;
getFilterActions?: () => Promise;
- geoFields: GeoFieldWithIndex[];
+ geoFieldNames: string[];
hasLockedTooltips: boolean;
isDrawingFilter: boolean;
layerList: ILayer[];
@@ -163,8 +161,10 @@ export class TooltipControl extends Component {
const actions = [];
const geometry = this._getFeatureGeometry({ layerId, featureId });
- const geoFieldsForFeature = this._filterGeoFieldsByFeatureGeometry(geometry);
- if (geometry && geoFieldsForFeature.length && this.props.addFilters) {
+ const isPolygon =
+ geometry &&
+ (geometry.type === GEO_JSON_TYPE.POLYGON || geometry.type === GEO_JSON_TYPE.MULTI_POLYGON);
+ if (isPolygon && this.props.geoFieldNames.length && this.props.addFilters) {
actions.push({
label: i18n.translate('xpack.maps.tooltip.action.filterByGeometryLabel', {
defaultMessage: 'Filter by geometry',
@@ -175,8 +175,8 @@ export class TooltipControl extends Component {
onClose={() => {
this.props.closeOnClickTooltip(tooltipId);
}}
- geometry={geometry}
- geoFields={geoFieldsForFeature}
+ geometry={geometry!}
+ geoFieldNames={this.props.geoFieldNames}
addFilters={this.props.addFilters}
getFilterActions={this.props.getFilterActions}
getActionContext={this.props.getActionContext}
@@ -191,29 +191,6 @@ export class TooltipControl extends Component {
return actions;
}
- _filterGeoFieldsByFeatureGeometry(geometry: Geometry | null) {
- if (!geometry) {
- return [];
- }
-
- // line geometry can only create filters for geo_shape fields.
- if (
- geometry.type === GEO_JSON_TYPE.LINE_STRING ||
- geometry.type === GEO_JSON_TYPE.MULTI_LINE_STRING
- ) {
- return this.props.geoFields.filter(({ geoFieldType }) => {
- return geoFieldType === ES_GEO_FIELD_TYPE.GEO_SHAPE;
- });
- }
-
- // TODO support geo distance filters for points
- if (geometry.type === GEO_JSON_TYPE.POINT || geometry.type === GEO_JSON_TYPE.MULTI_POINT) {
- return [];
- }
-
- return this.props.geoFields;
- }
-
_getTooltipFeatures(
mbFeatures: MapboxGeoJSONFeature[],
isLocked: boolean,
diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap
index 168a070b077441..245cf6e5b2a482 100644
--- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap
+++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap
@@ -29,18 +29,7 @@ exports[`Should show all controls 1`] = `
-
+
diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/index.ts b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/index.ts
index d1008edfd572d9..7d176c13da049f 100644
--- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/index.ts
+++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/index.ts
@@ -5,4 +5,16 @@
* 2.0.
*/
-export { ToolbarOverlay } from './toolbar_overlay';
+import { connect } from 'react-redux';
+import { MapStoreState } from '../../reducers/store';
+import { getGeoFieldNames } from '../../selectors/map_selectors';
+import { ToolbarOverlay } from './toolbar_overlay';
+
+function mapStateToProps(state: MapStoreState) {
+ return {
+ showToolsControl: getGeoFieldNames(state).length !== 0,
+ };
+}
+
+const connected = connect(mapStateToProps)(ToolbarOverlay);
+export { connected as ToolbarOverlay };
diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx
index 28b5ab9c78f401..9efbd82fe390ac 100644
--- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx
+++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx
@@ -21,22 +21,20 @@ import { ToolbarOverlay } from './toolbar_overlay';
test('Should only show set view control', async () => {
const component = shallow(
-
+
);
expect(component).toMatchSnapshot();
});
test('Should show all controls', async () => {
- const geoFieldWithIndex = {
- geoFieldName: 'myGeoFieldName',
- geoFieldType: 'geo_point',
- indexPatternTitle: 'myIndex',
- indexPatternId: '1',
- };
const component = shallow(
{}}
- geoFields={[geoFieldWithIndex]}
+ showToolsControl={true}
showFitToBoundsButton={true}
showTimesliderButton={true}
/>
diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.tsx
index 41c6c1f7c4a7cd..2b357932189695 100644
--- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.tsx
+++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.tsx
@@ -13,11 +13,10 @@ import { SetViewControl } from './set_view_control';
import { ToolsControl } from './tools_control';
import { FitToData } from './fit_to_data';
import { TimesliderToggleButton } from './timeslider_toggle_button';
-import { GeoFieldWithIndex } from '../../components/geo_field_with_index';
export interface Props {
addFilters?: ((filters: Filter[], actionId: string) => Promise) | null;
- geoFields: GeoFieldWithIndex[];
+ showToolsControl: boolean;
getFilterActions?: () => Promise;
getActionContext?: () => ActionExecutionContext;
showFitToBoundsButton: boolean;
@@ -26,10 +25,9 @@ export interface Props {
export function ToolbarOverlay(props: Props) {
const toolsButton =
- props.addFilters && props.geoFields.length ? (
+ props.addFilters && props.showToolsControl ? (
diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap
index b6d217d6907647..aa5c6aa42c77de 100644
--- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap
+++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/__snapshots__/tools_control.test.tsx.snap
@@ -56,16 +56,6 @@ exports[`Should render cancel button when drawing 1`] = `
"content": ,
"id": 3,
@@ -187,16 +157,6 @@ exports[`renders 1`] = `
"content": ,
"id": 3,
diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx
index 6779fe945137e8..9ea20914c64222 100644
--- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx
+++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx
@@ -22,7 +22,6 @@ import { DRAW_TYPE, ES_GEO_FIELD_TYPE, ES_SPATIAL_RELATIONS } from '../../../../
// @ts-expect-error
import { GeometryFilterForm } from '../../../components/geometry_filter_form';
import { DistanceFilterForm } from '../../../components/distance_filter_form';
-import { GeoFieldWithIndex } from '../../../components/geo_field_with_index';
import { DrawState } from '../../../../common/descriptor_types';
const DRAW_SHAPE_LABEL = i18n.translate('xpack.maps.toolbarOverlay.drawShapeLabel', {
@@ -54,7 +53,6 @@ const DRAW_DISTANCE_LABEL_SHORT = i18n.translate(
export interface Props {
cancelDraw: () => void;
- geoFields: GeoFieldWithIndex[];
initiateDraw: (drawState: DrawState) => void;
isDrawingFilter: boolean;
getFilterActions?: () => Promise;
@@ -98,9 +96,6 @@ export class ToolsControl extends Component {
_initiateBoundsDraw = (options: {
actionId: string;
geometryLabel: string;
- indexPatternId: string;
- geoFieldName: string;
- geoFieldType: ES_GEO_FIELD_TYPE;
relation: ES_SPATIAL_RELATIONS;
}) => {
this.props.initiateDraw({
@@ -110,12 +105,7 @@ export class ToolsControl extends Component {
this._closePopover();
};
- _initiateDistanceDraw = (options: {
- actionId: string;
- filterLabel: string;
- indexPatternId: string;
- geoFieldName: string;
- }) => {
+ _initiateDistanceDraw = (options: { actionId: string; filterLabel: string }) => {
this.props.initiateDraw({
drawType: DRAW_TYPE.DISTANCE,
...options,
@@ -154,7 +144,6 @@ export class ToolsControl extends Component {
{
{
Date: Wed, 2 Jun 2021 14:43:47 +0200
Subject: [PATCH 03/77] Add "Risk Matrix" section to the PR template (#100649)
---
.github/PULL_REQUEST_TEMPLATE.md | 20 ++++++++++
RISK_MATRIX.mdx | 63 ++++++++++++++++++++++++++++++++
2 files changed, 83 insertions(+)
create mode 100644 RISK_MATRIX.mdx
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 2a5fc914662b63..336f7e5165d07f 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -2,6 +2,7 @@
Summarize your PR. If it involves visual changes include a screenshot or gif.
+
### Checklist
Delete any items that are not applicable to this PR.
@@ -15,6 +16,25 @@ Delete any items that are not applicable to this PR.
- [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
+
+### Risk Matrix
+
+Delete this section if it is not applicable to this PR.
+
+Before closing this PR, invite QA, stakeholders, and other developers to
+identify risks that should be tested prior to the change/feature release.
+
+When forming the risk matrix, consider some of the following examples and how
+they may potentially impact the change:
+
+| Risk | Probability | Severity | Mitigation/Notes |
+|---------------------------|-------------|----------|-------------------------|
+| Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. |
+| Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. |
+| Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. |
+| [See more potential risk examples](../RISK_MATRIX.mdx) |
+
+
### For maintainers
- [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
diff --git a/RISK_MATRIX.mdx b/RISK_MATRIX.mdx
new file mode 100644
index 00000000000000..71b0198489934a
--- /dev/null
+++ b/RISK_MATRIX.mdx
@@ -0,0 +1,63 @@
+# Risk consideration
+
+When merging a new feature of considerable size or modifying an existing one,
+consider adding a *Risk Matrix* section to your PR in collaboration with other
+developers on your team and the QA team.
+
+Below are some general themes to consider for the *Risk Matrix*. (Feel free to
+add to this list.)
+
+
+## General risks
+
+- What happens when your feature is used in a non-default space or a custom
+ space?
+- What happens when there are multiple Kibana nodes using the same Elasticsearch
+ cluster?
+- What happens when a plugin you depend on is disabled?
+- What happens when a feature you depend on is disabled?
+- Is your change working correctly regardless of `kibana.yml` configuration or
+ UI Setting configuration? (For example, does it support both
+ `state:storeInSessionStorage` UI setting states?)
+- What happens when a third party integration you depend on is not responding?
+- How is authentication handled with third party services?
+- Does the feature work in Elastic Cloud?
+- Does the feature create a setting that needs to be exposed, or configured
+ differently than the default, on the Elastic Cloud?
+- Is there a significant performance impact that may affect Cloud Kibana
+ instances?
+- Does your feature need to be aware of running in a container?
+- Does the feature Work with security disabled, or fails gracefully?
+- Are there performance risks associated with your feature? Does it potentially
+ access or create: (1) many fields; (2) many indices; (3) lots of data;
+ (4) lots of saved objects; (5) large saved objects.
+- Could this cause memory to leak in either the browser or server?
+- Will your feature still work if Kibana is run behind a reverse proxy?
+- Does your feature affect other plugins?
+- If you write to the file system, what happens if Kibana node goes down? What
+ happens if there are multiple Kibana nodes?
+- Are migrations handled gracefully? Does the feature affect old indices or
+ saved objects?
+- Are you using any technologies, protocols, techniques, conventions, libraries,
+ NPM modules, etc. that may be new or unprecedented in Kibana?
+
+
+## Security risks
+
+Check to ensure that best practices are used to mitigate common vulnerabilities:
+
+- Cross-site scripting (XSS)
+- Cross-site request forgery (CSRF)
+- Remote-code execution (RCE)
+- Server-side request forgery (SSRF)
+- Prototype pollution
+- Information disclosure
+- Tabnabbing
+
+In addition to these risks, in general, server-side input validation should be
+implemented as strictly as possible. Extra care should be taken when user input
+is used to construct URLs or data structures; this is a common source of
+injection attacks and other vulnerabilities. For more information on all of
+these topics, see [Security best practices][security-best-practices].
+
+[security-best-practices]: https://www.elastic.co/guide/en/kibana/master/security-best-practices.html
From dfd6ec9243b63bdfc223acff5f53dd2324f1a887 Mon Sep 17 00:00:00 2001
From: Ahmad Bamieh
Date: Wed, 2 Jun 2021 15:52:14 +0300
Subject: [PATCH 04/77] [Deprecations service] make
`correctiveActions.manualSteps` required (#100997)
Co-authored-by: igoristic
Co-authored-by: Larry Gregory
---
...r.deprecationsdetails.correctiveactions.md | 2 +-
...-plugin-core-server.deprecationsdetails.md | 2 +-
.../kbn-config/src/config_service.test.ts | 38 +++++-
packages/kbn-config/src/deprecation/types.ts | 4 +-
.../deprecations/deprecations_client.test.ts | 11 +-
.../deprecation/core_deprecations.test.ts | 6 +-
.../config/deprecation/core_deprecations.ts | 116 +++++++++++++++++-
.../deprecations/deprecations_service.ts | 4 +-
src/core/server/deprecations/types.ts | 8 +-
.../elasticsearch/elasticsearch_config.ts | 22 ++++
src/core/server/kibana_config.ts | 6 +
.../saved_objects/saved_objects_config.ts | 3 +
src/core/server/server.api.md | 2 +-
.../core_plugin_deprecations/server/config.ts | 5 +
.../core_plugin_deprecations/server/plugin.ts | 4 +-
.../test_suites/core/deprecations.ts | 13 +-
x-pack/plugins/actions/server/index.ts | 37 +++++-
x-pack/plugins/banners/server/config.ts | 6 +
.../plugins/monitoring/server/deprecations.ts | 23 +++-
.../reporting/server/config/index.test.ts | 2 +-
.../plugins/reporting/server/config/index.ts | 15 ++-
.../server/config_deprecations.test.ts | 4 +-
.../security/server/config_deprecations.ts | 24 +++-
x-pack/plugins/spaces/server/config.ts | 3 +
x-pack/plugins/task_manager/server/index.ts | 12 ++
.../client_integration/overview.test.ts | 2 +-
26 files changed, 336 insertions(+), 38 deletions(-)
diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.correctiveactions.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.correctiveactions.md
index e362bc4e0329c4..447823a5c34910 100644
--- a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.correctiveactions.md
+++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.correctiveactions.md
@@ -15,6 +15,6 @@ correctiveActions: {
[key: string]: any;
};
};
- manualSteps?: string[];
+ manualSteps: string[];
};
```
diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md
index 6e46ce0b8611f7..7592b8486d950f 100644
--- a/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md
+++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsdetails.md
@@ -14,7 +14,7 @@ export interface DeprecationsDetails
| Property | Type | Description |
| --- | --- | --- |
-| [correctiveActions](./kibana-plugin-core-server.deprecationsdetails.correctiveactions.md) | {
api?: {
path: string;
method: 'POST' | 'PUT';
body?: {
[key: string]: any;
};
};
manualSteps?: string[];
}
| |
+| [correctiveActions](./kibana-plugin-core-server.deprecationsdetails.correctiveactions.md) | {
api?: {
path: string;
method: 'POST' | 'PUT';
body?: {
[key: string]: any;
};
};
manualSteps: string[];
}
| |
| [deprecationType](./kibana-plugin-core-server.deprecationsdetails.deprecationtype.md) | 'config' | 'feature'
| (optional) Used to identify between different deprecation types. Example use case: in Upgrade Assistant, we may want to allow the user to sort by deprecation type or show each type in a separate tab.Feel free to add new types if necessary. Predefined types are necessary to reduce having similar definitions with different keywords across kibana deprecations. |
| [documentationUrl](./kibana-plugin-core-server.deprecationsdetails.documentationurl.md) | string
| |
| [level](./kibana-plugin-core-server.deprecationsdetails.level.md) | 'warning' | 'critical' | 'fetch_error'
| levels: - warning: will not break deployment upon upgrade - critical: needs to be addressed before upgrade. - fetch\_error: Deprecations service failed to grab the deprecation details for the domain. |
diff --git a/packages/kbn-config/src/config_service.test.ts b/packages/kbn-config/src/config_service.test.ts
index c2d4f15b6d9153..b1b622381abb1c 100644
--- a/packages/kbn-config/src/config_service.test.ts
+++ b/packages/kbn-config/src/config_service.test.ts
@@ -418,8 +418,14 @@ test('logs deprecation warning during validation', async () => {
const configService = new ConfigService(rawConfig, defaultEnv, logger);
mockApplyDeprecations.mockImplementationOnce((config, deprecations, createAddDeprecation) => {
const addDeprecation = createAddDeprecation!('');
- addDeprecation({ message: 'some deprecation message' });
- addDeprecation({ message: 'another deprecation message' });
+ addDeprecation({
+ message: 'some deprecation message',
+ correctiveActions: { manualSteps: ['do X'] },
+ });
+ addDeprecation({
+ message: 'another deprecation message',
+ correctiveActions: { manualSteps: ['do Y'] },
+ });
return { config, changedPaths: mockedChangedPaths };
});
@@ -444,13 +450,24 @@ test('does not log warnings for silent deprecations during validation', async ()
mockApplyDeprecations
.mockImplementationOnce((config, deprecations, createAddDeprecation) => {
const addDeprecation = createAddDeprecation!('');
- addDeprecation({ message: 'some deprecation message', silent: true });
- addDeprecation({ message: 'another deprecation message' });
+ addDeprecation({
+ message: 'some deprecation message',
+ correctiveActions: { manualSteps: ['do X'] },
+ silent: true,
+ });
+ addDeprecation({
+ message: 'another deprecation message',
+ correctiveActions: { manualSteps: ['do Y'] },
+ });
return { config, changedPaths: mockedChangedPaths };
})
.mockImplementationOnce((config, deprecations, createAddDeprecation) => {
const addDeprecation = createAddDeprecation!('');
- addDeprecation({ message: 'I am silent', silent: true });
+ addDeprecation({
+ message: 'I am silent',
+ silent: true,
+ correctiveActions: { manualSteps: ['do Z'] },
+ });
return { config, changedPaths: mockedChangedPaths };
});
@@ -519,7 +536,11 @@ describe('getHandledDeprecatedConfigs', () => {
mockApplyDeprecations.mockImplementationOnce((config, deprecations, createAddDeprecation) => {
deprecations.forEach((deprecation) => {
const addDeprecation = createAddDeprecation!(deprecation.path);
- addDeprecation({ message: `some deprecation message`, documentationUrl: 'some-url' });
+ addDeprecation({
+ message: `some deprecation message`,
+ documentationUrl: 'some-url',
+ correctiveActions: { manualSteps: ['do X'] },
+ });
});
return { config, changedPaths: mockedChangedPaths };
});
@@ -532,6 +553,11 @@ describe('getHandledDeprecatedConfigs', () => {
"base",
Array [
Object {
+ "correctiveActions": Object {
+ "manualSteps": Array [
+ "do X",
+ ],
+ },
"documentationUrl": "some-url",
"message": "some deprecation message",
},
diff --git a/packages/kbn-config/src/deprecation/types.ts b/packages/kbn-config/src/deprecation/types.ts
index 0522365ad76c11..1791dac060e2bf 100644
--- a/packages/kbn-config/src/deprecation/types.ts
+++ b/packages/kbn-config/src/deprecation/types.ts
@@ -25,8 +25,8 @@ export interface DeprecatedConfigDetails {
silent?: boolean;
/* (optional) link to the documentation for more details on the deprecation. */
documentationUrl?: string;
- /* (optional) corrective action needed to fix this deprecation. */
- correctiveActions?: {
+ /* corrective action needed to fix this deprecation. */
+ correctiveActions: {
/**
* Specify a list of manual steps our users need to follow
* to fix the deprecation before upgrade.
diff --git a/src/core/public/deprecations/deprecations_client.test.ts b/src/core/public/deprecations/deprecations_client.test.ts
index 2f52f7b4af195b..a998a03772cca8 100644
--- a/src/core/public/deprecations/deprecations_client.test.ts
+++ b/src/core/public/deprecations/deprecations_client.test.ts
@@ -90,6 +90,7 @@ describe('DeprecationsClient', () => {
path: 'some-path',
method: 'POST',
},
+ manualSteps: ['manual-step'],
},
};
@@ -104,7 +105,9 @@ describe('DeprecationsClient', () => {
domainId: 'testPluginId-1',
message: 'some-message',
level: 'warning',
- correctiveActions: {},
+ correctiveActions: {
+ manualSteps: ['manual-step'],
+ },
};
const isResolvable = deprecationsClient.isDeprecationResolvable(mockDeprecationDetails);
@@ -120,7 +123,9 @@ describe('DeprecationsClient', () => {
domainId: 'testPluginId-1',
message: 'some-message',
level: 'warning',
- correctiveActions: {},
+ correctiveActions: {
+ manualSteps: ['manual-step'],
+ },
};
const result = await deprecationsClient.resolveDeprecation(mockDeprecationDetails);
@@ -144,6 +149,7 @@ describe('DeprecationsClient', () => {
extra_param: 123,
},
},
+ manualSteps: ['manual-step'],
},
};
const result = await deprecationsClient.resolveDeprecation(mockDeprecationDetails);
@@ -176,6 +182,7 @@ describe('DeprecationsClient', () => {
extra_param: 123,
},
},
+ manualSteps: ['manual-step'],
},
};
http.fetch.mockRejectedValue({ body: { message: mockResponse } });
diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts
index a8063c317b3c50..06c7116c8bebb0 100644
--- a/src/core/server/config/deprecation/core_deprecations.test.ts
+++ b/src/core/server/config/deprecation/core_deprecations.test.ts
@@ -24,7 +24,7 @@ describe('core deprecations', () => {
const { messages } = applyCoreDeprecations();
expect(messages).toMatchInlineSnapshot(`
Array [
- "Environment variable CONFIG_PATH is deprecated. It has been replaced with KBN_PATH_CONF pointing to a config folder",
+ "Environment variable \\"CONFIG_PATH\\" is deprecated. It has been replaced with \\"KBN_PATH_CONF\\" pointing to a config folder",
]
`);
});
@@ -405,7 +405,7 @@ describe('core deprecations', () => {
});
expect(messages).toMatchInlineSnapshot(`
Array [
- "\\"logging.events.log\\" has been deprecated and will be removed in 8.0. Moving forward, log levels can be customized on a per-logger basis using the new logging configuration. ",
+ "\\"logging.events.log\\" has been deprecated and will be removed in 8.0. Moving forward, log levels can be customized on a per-logger basis using the new logging configuration.",
]
`);
});
@@ -418,7 +418,7 @@ describe('core deprecations', () => {
});
expect(messages).toMatchInlineSnapshot(`
Array [
- "\\"logging.events.error\\" has been deprecated and will be removed in 8.0. Moving forward, you can use \\"logging.root.level: error\\" in your logging configuration. ",
+ "\\"logging.events.error\\" has been deprecated and will be removed in 8.0. Moving forward, you can use \\"logging.root.level: error\\" in your logging configuration.",
]
`);
});
diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts
index 0722bbe0a71adc..222f92321d917c 100644
--- a/src/core/server/config/deprecation/core_deprecations.ts
+++ b/src/core/server/config/deprecation/core_deprecations.ts
@@ -11,7 +11,10 @@ import { ConfigDeprecationProvider, ConfigDeprecation } from '@kbn/config';
const configPathDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => {
if (process.env?.CONFIG_PATH) {
addDeprecation({
- message: `Environment variable CONFIG_PATH is deprecated. It has been replaced with KBN_PATH_CONF pointing to a config folder`,
+ message: `Environment variable "CONFIG_PATH" is deprecated. It has been replaced with "KBN_PATH_CONF" pointing to a config folder`,
+ correctiveActions: {
+ manualSteps: ['Use "KBN_PATH_CONF" instead of "CONFIG_PATH" to point to a config folder.'],
+ },
});
}
};
@@ -20,6 +23,11 @@ const dataPathDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecati
if (process.env?.DATA_PATH) {
addDeprecation({
message: `Environment variable "DATA_PATH" will be removed. It has been replaced with kibana.yml setting "path.data"`,
+ correctiveActions: {
+ manualSteps: [
+ `Set 'path.data' in the config file or CLI flag with the value of the environment variable "DATA_PATH".`,
+ ],
+ },
});
}
};
@@ -32,6 +40,12 @@ const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, addDe
'will expect that all requests start with server.basePath rather than expecting you to rewrite ' +
'the requests in your reverse proxy. Set server.rewriteBasePath to false to preserve the ' +
'current behavior and silence this warning.',
+ correctiveActions: {
+ manualSteps: [
+ `Set 'server.rewriteBasePath' in the config file, CLI flag, or environment variable (in Docker only).`,
+ `Set to false to preserve the current behavior and silence this warning.`,
+ ],
+ },
});
}
};
@@ -41,6 +55,11 @@ const rewriteCorsSettings: ConfigDeprecation = (settings, fromPath, addDeprecati
if (typeof corsSettings === 'boolean') {
addDeprecation({
message: '"server.cors" is deprecated and has been replaced by "server.cors.enabled"',
+ correctiveActions: {
+ manualSteps: [
+ `Replace "server.cors: ${corsSettings}" with "server.cors.enabled: ${corsSettings}"`,
+ ],
+ },
});
return {
@@ -72,6 +91,9 @@ const cspRulesDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecati
if (sourceList.find((source) => source.includes(NONCE_STRING))) {
addDeprecation({
message: `csp.rules no longer supports the {nonce} syntax. Replacing with 'self' in ${policy}`,
+ correctiveActions: {
+ manualSteps: [`Replace {nonce} syntax with 'self' in ${policy}`],
+ },
});
sourceList = sourceList.filter((source) => !source.includes(NONCE_STRING));
@@ -87,6 +109,9 @@ const cspRulesDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecati
) {
addDeprecation({
message: `csp.rules must contain the 'self' source. Automatically adding to ${policy}.`,
+ correctiveActions: {
+ manualSteps: [`Add 'self' source to ${policy}.`],
+ },
});
sourceList.push(SELF_STRING);
}
@@ -111,6 +136,12 @@ const mapManifestServiceUrlDeprecation: ConfigDeprecation = (
'of the Elastic Maps Service settings. These settings have moved to the "map.emsTileApiUrl" and ' +
'"map.emsFileApiUrl" settings instead. These settings are for development use only and should not be ' +
'modified for use in production environments.',
+ correctiveActions: {
+ manualSteps: [
+ `Use "map.emsTileApiUrl" and "map.emsFileApiUrl" config instead of "map.manifestServiceUrl".`,
+ `These settings are for development use only and should not be modified for use in production environments.`,
+ ],
+ },
});
}
};
@@ -125,12 +156,28 @@ const opsLoggingEventDeprecation: ConfigDeprecation = (settings, fromPath, addDe
'in 8.0. To access ops data moving forward, please enable debug logs for the ' +
'"metrics.ops" context in your logging configuration. For more details, see ' +
'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "logging.events.ops" from your kibana settings.`,
+ `Enable debug logs for the "metrics.ops" context in your logging configuration`,
+ ],
+ },
});
}
};
const requestLoggingEventDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecation) => {
if (settings.logging?.events?.request || settings.logging?.events?.response) {
+ const removeConfigsSteps = [];
+
+ if (settings.logging?.events?.request) {
+ removeConfigsSteps.push(`Remove "logging.events.request" from your kibana configs.`);
+ }
+
+ if (settings.logging?.events?.response) {
+ removeConfigsSteps.push(`Remove "logging.events.response" from your kibana configs.`);
+ }
+
addDeprecation({
documentationUrl:
'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingevents',
@@ -139,6 +186,12 @@ const requestLoggingEventDeprecation: ConfigDeprecation = (settings, fromPath, a
'in 8.0. To access request and/or response data moving forward, please enable debug logs for the ' +
'"http.server.response" context in your logging configuration. For more details, see ' +
'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx',
+ correctiveActions: {
+ manualSteps: [
+ ...removeConfigsSteps,
+ `enable debug logs for the "http.server.response" context in your logging configuration.`,
+ ],
+ },
});
}
};
@@ -153,6 +206,12 @@ const timezoneLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDe
'in 8.0. To set the timezone moving forward, please add a timezone date modifier to the log pattern ' +
'in your logging configuration. For more details, see ' +
'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "logging.timezone" from your kibana configs.`,
+ `To set the timezone add a timezone date modifier to the log pattern in your logging configuration.`,
+ ],
+ },
});
}
};
@@ -167,6 +226,12 @@ const destLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDeprec
'in 8.0. To set the destination moving forward, you can use the "console" appender ' +
'in your logging configuration or define a custom one. For more details, see ' +
'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "logging.dest" from your kibana configs.`,
+ `To set the destination use the "console" appender in your logging configuration or define a custom one.`,
+ ],
+ },
});
}
};
@@ -179,6 +244,12 @@ const quietLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDepre
message:
'"logging.quiet" has been deprecated and will be removed ' +
'in 8.0. Moving forward, you can use "logging.root.level:error" in your logging configuration. ',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "logging.quiet" from your kibana configs.`,
+ `Use "logging.root.level:error" in your logging configuration.`,
+ ],
+ },
});
}
};
@@ -191,6 +262,12 @@ const silentLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDepr
message:
'"logging.silent" has been deprecated and will be removed ' +
'in 8.0. Moving forward, you can use "logging.root.level:off" in your logging configuration. ',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "logging.silent" from your kibana configs.`,
+ `Use "logging.root.level:off" in your logging configuration.`,
+ ],
+ },
});
}
};
@@ -203,6 +280,12 @@ const verboseLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDep
message:
'"logging.verbose" has been deprecated and will be removed ' +
'in 8.0. Moving forward, you can use "logging.root.level:all" in your logging configuration. ',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "logging.verbose" from your kibana configs.`,
+ `Use "logging.root.level:all" in your logging configuration.`,
+ ],
+ },
});
}
};
@@ -223,6 +306,12 @@ const jsonLoggingDeprecation: ConfigDeprecation = (settings, fromPath, addDeprec
'There is currently no default layout for custom appenders and each one must be declared explicitly. ' +
'For more details, see ' +
'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "logging.json" from your kibana configs.`,
+ `Configure the "appender.layout" property for every custom appender in your logging configuration.`,
+ ],
+ },
});
}
};
@@ -237,6 +326,12 @@ const logRotateDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecat
'Moving forward, you can enable log rotation using the "rolling-file" appender for a logger ' +
'in your logging configuration. For more details, see ' +
'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#rolling-file-appender',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "logging.rotate" from your kibana configs.`,
+ `Enable log rotation using the "rolling-file" appender for a logger in your logging configuration.`,
+ ],
+ },
});
}
};
@@ -248,7 +343,13 @@ const logEventsLogDeprecation: ConfigDeprecation = (settings, fromPath, addDepre
'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingevents',
message:
'"logging.events.log" has been deprecated and will be removed ' +
- 'in 8.0. Moving forward, log levels can be customized on a per-logger basis using the new logging configuration. ',
+ 'in 8.0. Moving forward, log levels can be customized on a per-logger basis using the new logging configuration.',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "logging.events.log" from your kibana configs.`,
+ `Customize log levels can be per-logger using the new logging configuration.`,
+ ],
+ },
});
}
};
@@ -260,7 +361,13 @@ const logEventsErrorDeprecation: ConfigDeprecation = (settings, fromPath, addDep
'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingevents',
message:
'"logging.events.error" has been deprecated and will be removed ' +
- 'in 8.0. Moving forward, you can use "logging.root.level: error" in your logging configuration. ',
+ 'in 8.0. Moving forward, you can use "logging.root.level: error" in your logging configuration.',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "logging.events.error" from your kibana configs.`,
+ `Use "logging.root.level: error" in your logging configuration.`,
+ ],
+ },
});
}
};
@@ -271,6 +378,9 @@ const logFilterDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecat
documentationUrl:
'https://github.com/elastic/kibana/blob/master/src/core/server/logging/README.mdx#loggingfilter',
message: '"logging.filter" has been deprecated and will be removed in 8.0.',
+ correctiveActions: {
+ manualSteps: [`Remove "logging.filter" from your kibana configs.`],
+ },
});
}
};
diff --git a/src/core/server/deprecations/deprecations_service.ts b/src/core/server/deprecations/deprecations_service.ts
index 205dd964468c1a..ede7f859ffd0d4 100644
--- a/src/core/server/deprecations/deprecations_service.ts
+++ b/src/core/server/deprecations/deprecations_service.ts
@@ -116,7 +116,7 @@ export interface DeprecationsSetupDeps {
export class DeprecationsService implements CoreService {
private readonly logger: Logger;
- constructor(private readonly coreContext: CoreContext) {
+ constructor(private readonly coreContext: Pick) {
this.logger = coreContext.logger.get('deprecations-service');
}
@@ -154,7 +154,7 @@ export class DeprecationsService implements CoreService [
if (es.username === 'elastic') {
addDeprecation({
message: `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana_system" user instead.`,
+ correctiveActions: {
+ manualSteps: [`Replace [${fromPath}.username] from "elastic" to "kibana_system".`],
+ },
});
} else if (es.username === 'kibana') {
addDeprecation({
message: `Setting [${fromPath}.username] to "kibana" is deprecated. You should use the "kibana_system" user instead.`,
+ correctiveActions: {
+ manualSteps: [`Replace [${fromPath}.username] from "kibana" to "kibana_system".`],
+ },
});
}
if (es.ssl?.key !== undefined && es.ssl?.certificate === undefined) {
addDeprecation({
message: `Setting [${fromPath}.ssl.key] without [${fromPath}.ssl.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.`,
+ correctiveActions: {
+ manualSteps: [
+ `Set [${fromPath}.ssl.certificate] in your kibana configs to enable TLS client authentication to Elasticsearch.`,
+ ],
+ },
});
} else if (es.ssl?.certificate !== undefined && es.ssl?.key === undefined) {
addDeprecation({
message: `Setting [${fromPath}.ssl.certificate] without [${fromPath}.ssl.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.`,
+ correctiveActions: {
+ manualSteps: [
+ `Set [${fromPath}.ssl.key] in your kibana configs to enable TLS client authentication to Elasticsearch.`,
+ ],
+ },
});
} else if (es.logQueries === true) {
addDeprecation({
message: `Setting [${fromPath}.logQueries] is deprecated and no longer used. You should set the log level to "debug" for the "elasticsearch.queries" context in "logging.loggers" or use "logging.verbose: true".`,
+ correctiveActions: {
+ manualSteps: [
+ `Remove Setting [${fromPath}.logQueries] from your kibana configs`,
+ `Set the log level to "debug" for the "elasticsearch.queries" context in "logging.loggers".`,
+ ],
+ },
});
}
return;
diff --git a/src/core/server/kibana_config.ts b/src/core/server/kibana_config.ts
index 623ffa86c0e8d9..77ee3197b988db 100644
--- a/src/core/server/kibana_config.ts
+++ b/src/core/server/kibana_config.ts
@@ -18,6 +18,12 @@ const deprecations: ConfigDeprecationProvider = () => [
addDeprecation({
message: `"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`,
documentationUrl: 'https://ela.st/kbn-remove-legacy-multitenancy',
+ correctiveActions: {
+ manualSteps: [
+ `If you rely on this setting to achieve multitenancy you should use Spaces, cross-cluster replication, or cross-cluster search instead.`,
+ `To migrate to Spaces, we encourage using saved object management to export your saved objects from a tenant into the default tenant in a space.`,
+ ],
+ },
});
}
return settings;
diff --git a/src/core/server/saved_objects/saved_objects_config.ts b/src/core/server/saved_objects/saved_objects_config.ts
index 7182df74c597fa..c62d322f0bf8d9 100644
--- a/src/core/server/saved_objects/saved_objects_config.ts
+++ b/src/core/server/saved_objects/saved_objects_config.ts
@@ -29,6 +29,9 @@ const migrationDeprecations: ConfigDeprecationProvider = () => [
message:
'"migrations.enableV2" is deprecated and will be removed in an upcoming release without any further notice.',
documentationUrl: 'https://ela.st/kbn-so-migration-v2',
+ correctiveActions: {
+ manualSteps: [`Remove "migrations.enableV2" from your kibana configs.`],
+ },
});
}
return settings;
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 0c35177f51f996..379e4147ae024c 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -872,7 +872,7 @@ export interface DeprecationsDetails {
[key: string]: any;
};
};
- manualSteps?: string[];
+ manualSteps: string[];
};
deprecationType?: 'config' | 'feature';
// (undocumented)
diff --git a/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts b/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts
index e051c39f681504..650567f761aa3c 100644
--- a/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts
+++ b/test/plugin_functional/plugins/core_plugin_deprecations/server/config.ts
@@ -26,6 +26,11 @@ const configSecretDeprecation: ConfigDeprecation = (settings, fromPath, addDepre
message:
'Kibana plugin functional tests will no longer allow corePluginDeprecations.secret ' +
'config to be set to anything except 42.',
+ correctiveActions: {
+ manualSteps: [
+ `This is an intentional deprecation for testing with no intention for having it fixed!`,
+ ],
+ },
});
}
return settings;
diff --git a/test/plugin_functional/plugins/core_plugin_deprecations/server/plugin.ts b/test/plugin_functional/plugins/core_plugin_deprecations/server/plugin.ts
index 65a2ce02aa0a45..9922e56f44bd9a 100644
--- a/test/plugin_functional/plugins/core_plugin_deprecations/server/plugin.ts
+++ b/test/plugin_functional/plugins/core_plugin_deprecations/server/plugin.ts
@@ -29,7 +29,9 @@ async function getDeprecations({
message: `SavedObject test-deprecations-plugin is still being used.`,
documentationUrl: 'another-test-url',
level: 'critical',
- correctiveActions: {},
+ correctiveActions: {
+ manualSteps: ['Step a', 'Step b'],
+ },
});
}
diff --git a/test/plugin_functional/test_suites/core/deprecations.ts b/test/plugin_functional/test_suites/core/deprecations.ts
index 99b1a79fb51e34..38a8b835b118c3 100644
--- a/test/plugin_functional/test_suites/core/deprecations.ts
+++ b/test/plugin_functional/test_suites/core/deprecations.ts
@@ -45,7 +45,11 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide
level: 'critical',
message:
'Kibana plugin functional tests will no longer allow corePluginDeprecations.secret config to be set to anything except 42.',
- correctiveActions: {},
+ correctiveActions: {
+ manualSteps: [
+ 'This is an intentional deprecation for testing with no intention for having it fixed!',
+ ],
+ },
documentationUrl: 'config-secret-doc-url',
deprecationType: 'config',
domainId: 'corePluginDeprecations',
@@ -64,7 +68,9 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide
message: 'SavedObject test-deprecations-plugin is still being used.',
documentationUrl: 'another-test-url',
level: 'critical',
- correctiveActions: {},
+ correctiveActions: {
+ manualSteps: ['Step a', 'Step b'],
+ },
domainId: 'corePluginDeprecations',
},
];
@@ -151,6 +157,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide
mockFail: true,
},
},
+ manualSteps: ['Step a', 'Step b'],
},
domainId: 'corePluginDeprecations',
})
@@ -178,6 +185,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide
mockFail: true,
},
},
+ manualSteps: ['Step a', 'Step b'],
},
domainId: 'corePluginDeprecations',
})
@@ -213,6 +221,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide
path: '/api/core_deprecations_resolve/',
body: { keyId },
},
+ manualSteps: ['Step a', 'Step b'],
},
domainId: 'corePluginDeprecations',
})
diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts
index 6a0f06b34d670e..692ff6fa0a5084 100644
--- a/x-pack/plugins/actions/server/index.ts
+++ b/x-pack/plugins/actions/server/index.ts
@@ -69,7 +69,18 @@ export const config: PluginConfigDescriptor = {
) {
addDeprecation({
message:
- '`xpack.actions.customHostSettings[].tls.rejectUnauthorized` is deprecated. Use `xpack.actions.customHostSettings[].tls.verificationMode` instead, with the setting `verificationMode:full` eql to `rejectUnauthorized:true`, and `verificationMode:none` eql to `rejectUnauthorized:false`.',
+ `"xpack.actions.customHostSettings[].tls.rejectUnauthorized" is deprecated.` +
+ `Use "xpack.actions.customHostSettings[].tls.verificationMode" instead, ` +
+ `with the setting "verificationMode:full" eql to "rejectUnauthorized:true", ` +
+ `and "verificationMode:none" eql to "rejectUnauthorized:false".`,
+ correctiveActions: {
+ manualSteps: [
+ `Remove "xpack.actions.customHostSettings[].tls.rejectUnauthorized" from your kibana configs.`,
+ `Use "xpack.actions.customHostSettings[].tls.verificationMode" ` +
+ `with the setting "verificationMode:full" eql to "rejectUnauthorized:true", ` +
+ `and "verificationMode:none" eql to "rejectUnauthorized:false".`,
+ ],
+ },
});
}
},
@@ -77,7 +88,17 @@ export const config: PluginConfigDescriptor = {
if (!!settings?.xpack?.actions?.rejectUnauthorized) {
addDeprecation({
message:
- '`xpack.actions.rejectUnauthorized` is deprecated. Use `xpack.actions.verificationMode` instead, with the setting `verificationMode:full` eql to `rejectUnauthorized:true`, and `verificationMode:none` eql to `rejectUnauthorized:false`.',
+ `"xpack.actions.rejectUnauthorized" is deprecated. Use "xpack.actions.verificationMode" instead, ` +
+ `with the setting "verificationMode:full" eql to "rejectUnauthorized:true", ` +
+ `and "verificationMode:none" eql to "rejectUnauthorized:false".`,
+ correctiveActions: {
+ manualSteps: [
+ `Remove "xpack.actions.rejectUnauthorized" from your kibana configs.`,
+ `Use "xpack.actions.verificationMode" ` +
+ `with the setting "verificationMode:full" eql to "rejectUnauthorized:true", ` +
+ `and "verificationMode:none" eql to "rejectUnauthorized:false".`,
+ ],
+ },
});
}
},
@@ -85,7 +106,17 @@ export const config: PluginConfigDescriptor = {
if (!!settings?.xpack?.actions?.proxyRejectUnauthorizedCertificates) {
addDeprecation({
message:
- '`xpack.actions.proxyRejectUnauthorizedCertificates` is deprecated. Use `xpack.actions.proxyVerificationMode` instead, with the setting `proxyVerificationMode:full` eql to `rejectUnauthorized:true`, and `proxyVerificationMode:none` eql to `rejectUnauthorized:false`.',
+ `"xpack.actions.proxyRejectUnauthorizedCertificates" is deprecated. Use "xpack.actions.proxyVerificationMode" instead, ` +
+ `with the setting "proxyVerificationMode:full" eql to "rejectUnauthorized:true",` +
+ `and "proxyVerificationMode:none" eql to "rejectUnauthorized:false".`,
+ correctiveActions: {
+ manualSteps: [
+ `Remove "xpack.actions.proxyRejectUnauthorizedCertificates" from your kibana configs.`,
+ `Use "xpack.actions.proxyVerificationMode" ` +
+ `with the setting "proxyVerificationMode:full" eql to "rejectUnauthorized:true",` +
+ `and "proxyVerificationMode:none" eql to "rejectUnauthorized:false".`,
+ ],
+ },
});
}
},
diff --git a/x-pack/plugins/banners/server/config.ts b/x-pack/plugins/banners/server/config.ts
index 5ee01ec2b0b19e..37b4c57fc2ce1f 100644
--- a/x-pack/plugins/banners/server/config.ts
+++ b/x-pack/plugins/banners/server/config.ts
@@ -45,6 +45,12 @@ export const config: PluginConfigDescriptor = {
if (pluginConfig?.placement === 'header') {
addDeprecation({
message: 'The `header` value for xpack.banners.placement has been replaced by `top`',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "xpack.banners.placement: header" from your kibana configs.`,
+ `Add "xpack.banners.placement: to" to your kibana configs instead.`,
+ ],
+ },
});
return {
set: [{ path: `${fromPath}.placement`, value: 'top' }],
diff --git a/x-pack/plugins/monitoring/server/deprecations.ts b/x-pack/plugins/monitoring/server/deprecations.ts
index 79b879b3a5f8b1..3e4d1627b0ae25 100644
--- a/x-pack/plugins/monitoring/server/deprecations.ts
+++ b/x-pack/plugins/monitoring/server/deprecations.ts
@@ -48,7 +48,12 @@ export const deprecations = ({
const emailNotificationsEnabled = get(config, 'cluster_alerts.email_notifications.enabled');
if (emailNotificationsEnabled && !get(config, CLUSTER_ALERTS_ADDRESS_CONFIG_KEY)) {
addDeprecation({
- message: `Config key [${fromPath}.${CLUSTER_ALERTS_ADDRESS_CONFIG_KEY}] will be required for email notifications to work in 7.0."`,
+ message: `Config key [${fromPath}.${CLUSTER_ALERTS_ADDRESS_CONFIG_KEY}] will be required for email notifications to work in 8.0."`,
+ correctiveActions: {
+ manualSteps: [
+ `Add [${fromPath}.${CLUSTER_ALERTS_ADDRESS_CONFIG_KEY}] to your kibana configs."`,
+ ],
+ },
});
}
return config;
@@ -59,10 +64,16 @@ export const deprecations = ({
if (es.username === 'elastic') {
addDeprecation({
message: `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana_system" user instead.`,
+ correctiveActions: {
+ manualSteps: [`Replace [${fromPath}.username] from "elastic" to "kibana_system".`],
+ },
});
} else if (es.username === 'kibana') {
addDeprecation({
message: `Setting [${fromPath}.username] to "kibana" is deprecated. You should use the "kibana_system" user instead.`,
+ correctiveActions: {
+ manualSteps: [`Replace [${fromPath}.username] from "kibana" to "kibana_system".`],
+ },
});
}
}
@@ -74,10 +85,20 @@ export const deprecations = ({
if (ssl.key !== undefined && ssl.certificate === undefined) {
addDeprecation({
message: `Setting [${fromPath}.key] without [${fromPath}.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.`,
+ correctiveActions: {
+ manualSteps: [
+ `Set [${fromPath}.ssl.certificate] in your kibana configs to enable TLS client authentication to Elasticsearch.`,
+ ],
+ },
});
} else if (ssl.certificate !== undefined && ssl.key === undefined) {
addDeprecation({
message: `Setting [${fromPath}.certificate] without [${fromPath}.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.`,
+ correctiveActions: {
+ manualSteps: [
+ `Set [${fromPath}.ssl.key] in your kibana configs to enable TLS client authentication to Elasticsearch.`,
+ ],
+ },
});
}
}
diff --git a/x-pack/plugins/reporting/server/config/index.test.ts b/x-pack/plugins/reporting/server/config/index.test.ts
index 327b03d679caed..b9665759f9f52b 100644
--- a/x-pack/plugins/reporting/server/config/index.test.ts
+++ b/x-pack/plugins/reporting/server/config/index.test.ts
@@ -45,7 +45,7 @@ describe('deprecations', () => {
const { messages } = applyReportingDeprecations({ roles: { enabled: true } });
expect(messages).toMatchInlineSnapshot(`
Array [
- "\\"xpack.reporting.roles\\" is deprecated. Granting reporting privilege through a \\"reporting_user\\" role will not be supported starting in 8.0. Please set 'xpack.reporting.roles.enabled' to 'false' and grant reporting privileges to users using Kibana application privileges **Management > Security > Roles**.",
+ "\\"xpack.reporting.roles\\" is deprecated. Granting reporting privilege through a \\"reporting_user\\" role will not be supported starting in 8.0. Please set \\"xpack.reporting.roles.enabled\\" to \\"false\\" and grant reporting privileges to users using Kibana application privileges **Management > Security > Roles**.",
]
`);
});
diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts
index 8927bd8ee94d50..d0c743f859b3c5 100644
--- a/x-pack/plugins/reporting/server/config/index.ts
+++ b/x-pack/plugins/reporting/server/config/index.ts
@@ -28,6 +28,12 @@ export const config: PluginConfigDescriptor = {
if (reporting?.index) {
addDeprecation({
message: `"${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`,
+ correctiveActions: {
+ manualSteps: [
+ `If you rely on this setting to achieve multitenancy you should use Spaces, cross-cluster replication, or cross-cluster search instead.`,
+ `To migrate to Spaces, we encourage using saved object management to export your saved objects from a tenant into the default tenant in a space.`,
+ ],
+ },
});
}
@@ -35,8 +41,15 @@ export const config: PluginConfigDescriptor = {
addDeprecation({
message:
`"${fromPath}.roles" is deprecated. Granting reporting privilege through a "reporting_user" role will not be supported ` +
- `starting in 8.0. Please set 'xpack.reporting.roles.enabled' to 'false' and grant reporting privileges to users ` +
+ `starting in 8.0. Please set "xpack.reporting.roles.enabled" to "false" and grant reporting privileges to users ` +
`using Kibana application privileges **Management > Security > Roles**.`,
+ correctiveActions: {
+ manualSteps: [
+ `Set 'xpack.reporting.roles.enabled' to 'false' in your kibana configs.`,
+ `Grant reporting privileges to users using Kibana application privileges` +
+ `under **Management > Security > Roles**.`,
+ ],
+ },
});
}
},
diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts
index a233d760359e5b..d2c75fd2331b99 100644
--- a/x-pack/plugins/security/server/config_deprecations.test.ts
+++ b/x-pack/plugins/security/server/config_deprecations.test.ts
@@ -243,7 +243,7 @@ describe('Config Deprecations', () => {
expect(migrated).toEqual(config);
expect(messages).toMatchInlineSnapshot(`
Array [
- "Defining \`xpack.security.authc.providers\` as an array of provider types is deprecated. Use extended \`object\` format instead.",
+ "Defining \\"xpack.security.authc.providers\\" as an array of provider types is deprecated. Use extended \\"object\\" format instead.",
]
`);
});
@@ -262,7 +262,7 @@ describe('Config Deprecations', () => {
expect(migrated).toEqual(config);
expect(messages).toMatchInlineSnapshot(`
Array [
- "Defining \`xpack.security.authc.providers\` as an array of provider types is deprecated. Use extended \`object\` format instead.",
+ "Defining \\"xpack.security.authc.providers\\" as an array of provider types is deprecated. Use extended \\"object\\" format instead.",
"Enabling both \`basic\` and \`token\` authentication providers in \`xpack.security.authc.providers\` is deprecated. Login page will only use \`token\` provider.",
]
`);
diff --git a/x-pack/plugins/security/server/config_deprecations.ts b/x-pack/plugins/security/server/config_deprecations.ts
index fdf8c0990cd4b1..7b659ec1c350d5 100644
--- a/x-pack/plugins/security/server/config_deprecations.ts
+++ b/x-pack/plugins/security/server/config_deprecations.ts
@@ -26,7 +26,13 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({
if (Array.isArray(settings?.xpack?.security?.authc?.providers)) {
addDeprecation({
message:
- 'Defining `xpack.security.authc.providers` as an array of provider types is deprecated. Use extended `object` format instead.',
+ `Defining "xpack.security.authc.providers" as an array of provider types is deprecated. ` +
+ `Use extended "object" format instead.`,
+ correctiveActions: {
+ manualSteps: [
+ `Use the extended object format for "xpack.security.authc.providers" in your Kibana configuration.`,
+ ],
+ },
});
}
},
@@ -46,6 +52,11 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({
addDeprecation({
message:
'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.',
+ correctiveActions: {
+ manualSteps: [
+ 'Remove either the `basic` or `token` auth provider in "xpack.security.authc.providers" from your Kibana configuration.',
+ ],
+ },
});
}
},
@@ -58,6 +69,11 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({
addDeprecation({
message:
'`xpack.security.authc.providers.saml..maxRedirectURLSize` is deprecated and is no longer used',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "xpack.security.authc.providers.saml..maxRedirectURLSize" from your Kibana configuration.`,
+ ],
+ },
});
}
},
@@ -67,6 +83,12 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({
message:
'Disabling the security plugin (`xpack.security.enabled`) will not be supported in the next major version (8.0). ' +
'To turn off security features, disable them in Elasticsearch instead.',
+ correctiveActions: {
+ manualSteps: [
+ `Remove "xpack.security.enabled" from your Kibana configuration.`,
+ `To turn off security features, disable them in Elasticsearch instead.`,
+ ],
+ },
});
}
},
diff --git a/x-pack/plugins/spaces/server/config.ts b/x-pack/plugins/spaces/server/config.ts
index 2ad8ed654ebb60..2a2f363d769f7f 100644
--- a/x-pack/plugins/spaces/server/config.ts
+++ b/x-pack/plugins/spaces/server/config.ts
@@ -28,6 +28,9 @@ const disabledDeprecation: ConfigDeprecation = (config, fromPath, addDeprecation
if (config.xpack?.spaces?.enabled === false) {
addDeprecation({
message: `Disabling the Spaces plugin (xpack.spaces.enabled) will not be supported in the next major version (8.0)`,
+ correctiveActions: {
+ manualSteps: [`Remove "xpack.spaces.enabled: false" from your Kibana configuration`],
+ },
});
}
};
diff --git a/x-pack/plugins/task_manager/server/index.ts b/x-pack/plugins/task_manager/server/index.ts
index 155b8246905d1e..80f0e298a8ac38 100644
--- a/x-pack/plugins/task_manager/server/index.ts
+++ b/x-pack/plugins/task_manager/server/index.ts
@@ -38,11 +38,23 @@ export const config: PluginConfigDescriptor = {
addDeprecation({
documentationUrl: 'https://ela.st/kbn-remove-legacy-multitenancy',
message: `"${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`,
+ correctiveActions: {
+ manualSteps: [
+ `If you rely on this setting to achieve multitenancy you should use Spaces, cross-cluster replication, or cross-cluster search instead.`,
+ `To migrate to Spaces, we encourage using saved object management to export your saved objects from a tenant into the default tenant in a space.`,
+ ],
+ },
});
}
if (taskManager?.max_workers > MAX_WORKERS_LIMIT) {
addDeprecation({
message: `setting "${fromPath}.max_workers" (${taskManager?.max_workers}) greater than ${MAX_WORKERS_LIMIT} is deprecated. Values greater than ${MAX_WORKERS_LIMIT} will not be supported starting in 8.0.`,
+ correctiveActions: {
+ manualSteps: [
+ `Maximum allowed value of "${fromPath}.max_workers" is ${MAX_WORKERS_LIMIT}.` +
+ `Replace "${fromPath}.max_workers: ${taskManager?.max_workers}" with (${MAX_WORKERS_LIMIT}).`,
+ ],
+ },
});
}
},
diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview.test.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview.test.ts
index f3f76c3a6688ed..85efaf38f32a73 100644
--- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview.test.ts
+++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview.test.ts
@@ -45,7 +45,7 @@ describe('Overview page', () => {
const kibanaDeprecationsMockResponse: DomainDeprecationDetails[] = [
{
- correctiveActions: {},
+ correctiveActions: { manualSteps: ['test-step'] },
domainId: 'xpack.spaces',
level: 'critical',
message:
From 8cb3dbc4abd9dffbe4c4c9bde6a66eeed96fd8b6 Mon Sep 17 00:00:00 2001
From: Joe Reuter
Date: Wed, 2 Jun 2021 15:58:47 +0200
Subject: [PATCH 05/77] [Lens] Time shift metrics (#98781)
---
...gins-data-public.aggconfig.gettimeshift.md | 15 +
...gins-data-public.aggconfig.hastimeshift.md | 15 +
...na-plugin-plugins-data-public.aggconfig.md | 2 +
...plugins-data-public.aggconfigs.forcenow.md | 11 +
...ic.aggconfigs.getsearchsourcetimefilter.md | 72 +++
...-public.aggconfigs.gettimeshiftinterval.md | 15 +
...ns-data-public.aggconfigs.gettimeshifts.md | 15 +
...ns-data-public.aggconfigs.hastimeshifts.md | 15 +
...a-plugin-plugins-data-public.aggconfigs.md | 7 +
...a-public.aggconfigs.postflighttransform.md | 22 +
...gins-data-public.aggconfigs.setforcenow.md | 22 +
.../data/common/search/aggs/agg_config.ts | 27 ++
.../common/search/aggs/agg_configs.test.ts | 346 ++++++++++++++
.../data/common/search/aggs/agg_configs.ts | 225 +++++++--
.../data/common/search/aggs/agg_type.ts | 4 +
.../buckets/_terms_other_bucket_helper.ts | 2 +-
.../search/aggs/buckets/bucket_agg_type.ts | 43 +-
.../search/aggs/buckets/date_histogram.ts | 11 +
.../data/common/search/aggs/buckets/terms.ts | 49 ++
.../common/search/aggs/metrics/avg_fn.test.ts | 1 +
.../data/common/search/aggs/metrics/avg_fn.ts | 7 +
.../search/aggs/metrics/bucket_avg_fn.test.ts | 3 +
.../search/aggs/metrics/bucket_avg_fn.ts | 7 +
.../search/aggs/metrics/bucket_max_fn.test.ts | 3 +
.../search/aggs/metrics/bucket_max_fn.ts | 7 +
.../search/aggs/metrics/bucket_min_fn.test.ts | 3 +
.../search/aggs/metrics/bucket_min_fn.ts | 7 +
.../search/aggs/metrics/bucket_sum_fn.test.ts | 3 +
.../search/aggs/metrics/bucket_sum_fn.ts | 7 +
.../aggs/metrics/cardinality_fn.test.ts | 1 +
.../search/aggs/metrics/cardinality_fn.ts | 7 +
.../data/common/search/aggs/metrics/count.ts | 7 +-
.../search/aggs/metrics/count_fn.test.ts | 1 +
.../common/search/aggs/metrics/count_fn.ts | 7 +
.../aggs/metrics/cumulative_sum_fn.test.ts | 4 +
.../search/aggs/metrics/cumulative_sum_fn.ts | 7 +
.../search/aggs/metrics/derivative_fn.test.ts | 4 +
.../search/aggs/metrics/derivative_fn.ts | 7 +
.../search/aggs/metrics/filtered_metric.ts | 2 +-
.../aggs/metrics/filtered_metric_fn.test.ts | 3 +
.../search/aggs/metrics/filtered_metric_fn.ts | 7 +
.../search/aggs/metrics/geo_bounds_fn.test.ts | 1 +
.../search/aggs/metrics/geo_bounds_fn.ts | 7 +
.../aggs/metrics/geo_centroid_fn.test.ts | 1 +
.../search/aggs/metrics/geo_centroid_fn.ts | 7 +
.../common/search/aggs/metrics/max_fn.test.ts | 1 +
.../data/common/search/aggs/metrics/max_fn.ts | 7 +
.../data/common/search/aggs/metrics/median.ts | 2 +-
.../search/aggs/metrics/median_fn.test.ts | 1 +
.../common/search/aggs/metrics/median_fn.ts | 7 +
.../search/aggs/metrics/metric_agg_type.ts | 19 +-
.../common/search/aggs/metrics/min_fn.test.ts | 1 +
.../data/common/search/aggs/metrics/min_fn.ts | 7 +
.../search/aggs/metrics/moving_avg_fn.test.ts | 4 +
.../search/aggs/metrics/moving_avg_fn.ts | 7 +
.../aggs/metrics/percentile_ranks_fn.test.ts | 2 +
.../aggs/metrics/percentile_ranks_fn.ts | 7 +
.../aggs/metrics/percentiles_fn.test.ts | 2 +
.../search/aggs/metrics/percentiles_fn.ts | 7 +
.../aggs/metrics/serial_diff_fn.test.ts | 4 +
.../search/aggs/metrics/serial_diff_fn.ts | 7 +
.../aggs/metrics/single_percentile_fn.ts | 7 +
.../aggs/metrics/std_deviation_fn.test.ts | 1 +
.../search/aggs/metrics/std_deviation_fn.ts | 7 +
.../common/search/aggs/metrics/sum_fn.test.ts | 1 +
.../data/common/search/aggs/metrics/sum_fn.ts | 7 +
.../search/aggs/metrics/top_hit_fn.test.ts | 2 +
.../common/search/aggs/metrics/top_hit_fn.ts | 7 +
src/plugins/data/common/search/aggs/types.ts | 1 +
.../data/common/search/aggs/utils/index.ts | 1 +
.../search/aggs/utils/parse_time_shift.ts | 29 ++
.../common/search/aggs/utils/time_splits.ts | 447 ++++++++++++++++++
.../esaggs/request_handler.test.ts | 1 +
.../expressions/esaggs/request_handler.ts | 29 +-
.../search/search_source/search_source.ts | 33 +-
.../data/common/search/tabify/tabify.ts | 2 +-
src/plugins/data/public/public.api.md | 47 ++
src/plugins/data/server/server.api.md | 4 +
.../public/components/agg_params_helper.ts | 3 +
.../run_pipeline/esaggs_timeshift.ts | 371 +++++++++++++++
.../test_suites/run_pipeline/index.ts | 1 +
.../workspace_panel/workspace_panel.tsx | 75 ++-
.../workspace_panel_wrapper.tsx | 17 +-
.../dimension_panel/advanced_options.tsx | 11 +-
.../dimension_panel/dimension_editor.tsx | 34 ++
.../dimension_panel/dimension_panel.test.tsx | 193 ++++++++
.../dimension_panel/time_scaling.tsx | 8 +-
.../dimension_panel/time_shift.tsx | 394 +++++++++++++++
.../indexpattern.test.ts | 169 ++++++-
.../indexpattern_datasource/indexpattern.tsx | 23 +-
.../definitions/calculations/counter_rate.tsx | 8 +-
.../calculations/cumulative_sum.tsx | 15 +-
.../definitions/calculations/differences.tsx | 6 +-
.../calculations/moving_average.tsx | 6 +-
.../definitions/calculations/utils.ts | 5 +-
.../operations/definitions/cardinality.tsx | 37 +-
.../operations/definitions/column_types.ts | 1 +
.../operations/definitions/count.tsx | 29 +-
.../operations/definitions/date_histogram.tsx | 53 ++-
.../operations/definitions/index.ts | 43 +-
.../operations/definitions/last_value.tsx | 32 +-
.../operations/definitions/metrics.tsx | 15 +-
.../operations/definitions/percentile.tsx | 40 +-
.../operations/definitions/terms/index.tsx | 121 ++++-
.../definitions/terms/terms.test.tsx | 109 ++++-
.../operations/layer_helpers.test.ts | 20 +-
.../operations/layer_helpers.ts | 70 ++-
.../operations/time_scale_utils.test.ts | 77 ++-
.../operations/time_scale_utils.ts | 30 +-
.../indexpattern_datasource/to_expression.ts | 17 +
.../public/indexpattern_datasource/utils.ts | 14 +-
x-pack/plugins/lens/public/types.ts | 25 +-
.../plugins/lens/server/routes/field_stats.ts | 10 +-
x-pack/test/functional/apps/lens/index.ts | 1 +
.../test/functional/apps/lens/time_shift.ts | 68 +++
.../test/functional/page_objects/lens_page.ts | 19 +
116 files changed, 3698 insertions(+), 222 deletions(-)
create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md
create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md
create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.forcenow.md
create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md
create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md
create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md
create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md
create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md
create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md
create mode 100644 src/plugins/data/common/search/aggs/utils/parse_time_shift.ts
create mode 100644 src/plugins/data/common/search/aggs/utils/time_splits.ts
create mode 100644 test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts
create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx
create mode 100644 x-pack/test/functional/apps/lens/time_shift.ts
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md
new file mode 100644
index 00000000000000..de0d41286c0bbb
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md
@@ -0,0 +1,15 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfig](./kibana-plugin-plugins-data-public.aggconfig.md) > [getTimeShift](./kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md)
+
+## AggConfig.getTimeShift() method
+
+Signature:
+
+```typescript
+getTimeShift(): undefined | moment.Duration;
+```
+Returns:
+
+`undefined | moment.Duration`
+
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md
new file mode 100644
index 00000000000000..024b0766ffd7b0
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md
@@ -0,0 +1,15 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfig](./kibana-plugin-plugins-data-public.aggconfig.md) > [hasTimeShift](./kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md)
+
+## AggConfig.hasTimeShift() method
+
+Signature:
+
+```typescript
+hasTimeShift(): boolean;
+```
+Returns:
+
+`boolean`
+
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.md
index d4a8eddf51cfcd..a96626d1a485d7 100644
--- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.md
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfig.md
@@ -46,8 +46,10 @@ export declare class AggConfig
| [getRequestAggs()](./kibana-plugin-plugins-data-public.aggconfig.getrequestaggs.md) | | |
| [getResponseAggs()](./kibana-plugin-plugins-data-public.aggconfig.getresponseaggs.md) | | |
| [getTimeRange()](./kibana-plugin-plugins-data-public.aggconfig.gettimerange.md) | | |
+| [getTimeShift()](./kibana-plugin-plugins-data-public.aggconfig.gettimeshift.md) | | |
| [getValue(bucket)](./kibana-plugin-plugins-data-public.aggconfig.getvalue.md) | | |
| [getValueBucketPath()](./kibana-plugin-plugins-data-public.aggconfig.getvaluebucketpath.md) | | Returns the bucket path containing the main value the agg will produce (e.g. for sum of bytes it will point to the sum, for median it will point to the 50 percentile in the percentile multi value bucket) |
+| [hasTimeShift()](./kibana-plugin-plugins-data-public.aggconfig.hastimeshift.md) | | |
| [isFilterable()](./kibana-plugin-plugins-data-public.aggconfig.isfilterable.md) | | |
| [makeLabel(percentageMode)](./kibana-plugin-plugins-data-public.aggconfig.makelabel.md) | | |
| [nextId(list)](./kibana-plugin-plugins-data-public.aggconfig.nextid.md) | static
| Calculate the next id based on the ids in this list {array} list - a list of objects with id properties |
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.forcenow.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.forcenow.md
new file mode 100644
index 00000000000000..8040c2939e2e45
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.forcenow.md
@@ -0,0 +1,11 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [forceNow](./kibana-plugin-plugins-data-public.aggconfigs.forcenow.md)
+
+## AggConfigs.forceNow property
+
+Signature:
+
+```typescript
+forceNow?: Date;
+```
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md
new file mode 100644
index 00000000000000..1f8bc1300a0a86
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md
@@ -0,0 +1,72 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [getSearchSourceTimeFilter](./kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md)
+
+## AggConfigs.getSearchSourceTimeFilter() method
+
+Signature:
+
+```typescript
+getSearchSourceTimeFilter(forceNow?: Date): RangeFilter[] | {
+ meta: {
+ index: string | undefined;
+ params: {};
+ alias: string;
+ disabled: boolean;
+ negate: boolean;
+ };
+ query: {
+ bool: {
+ should: {
+ bool: {
+ filter: {
+ range: {
+ [x: string]: {
+ gte: string;
+ lte: string;
+ };
+ };
+ }[];
+ };
+ }[];
+ minimum_should_match: number;
+ };
+ };
+ }[];
+```
+
+## Parameters
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| forceNow | Date
| |
+
+Returns:
+
+`RangeFilter[] | {
+ meta: {
+ index: string | undefined;
+ params: {};
+ alias: string;
+ disabled: boolean;
+ negate: boolean;
+ };
+ query: {
+ bool: {
+ should: {
+ bool: {
+ filter: {
+ range: {
+ [x: string]: {
+ gte: string;
+ lte: string;
+ };
+ };
+ }[];
+ };
+ }[];
+ minimum_should_match: number;
+ };
+ };
+ }[]`
+
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md
new file mode 100644
index 00000000000000..d15ccbc5dc0a1c
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md
@@ -0,0 +1,15 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [getTimeShiftInterval](./kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md)
+
+## AggConfigs.getTimeShiftInterval() method
+
+Signature:
+
+```typescript
+getTimeShiftInterval(): moment.Duration | undefined;
+```
+Returns:
+
+`moment.Duration | undefined`
+
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md
new file mode 100644
index 00000000000000..44ab25cf30eb2b
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md
@@ -0,0 +1,15 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [getTimeShifts](./kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md)
+
+## AggConfigs.getTimeShifts() method
+
+Signature:
+
+```typescript
+getTimeShifts(): Record;
+```
+Returns:
+
+`Record`
+
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md
new file mode 100644
index 00000000000000..db31e549666b45
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md
@@ -0,0 +1,15 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [hasTimeShifts](./kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md)
+
+## AggConfigs.hasTimeShifts() method
+
+Signature:
+
+```typescript
+hasTimeShifts(): boolean;
+```
+Returns:
+
+`boolean`
+
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md
index 02e9a63d95ba37..45333b6767cace 100644
--- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.md
@@ -22,6 +22,7 @@ export declare class AggConfigs
| --- | --- | --- | --- |
| [aggs](./kibana-plugin-plugins-data-public.aggconfigs.aggs.md) | | IAggConfig[]
| |
| [createAggConfig](./kibana-plugin-plugins-data-public.aggconfigs.createaggconfig.md) | | <T extends AggConfig = AggConfig>(params: CreateAggConfigParams, { addToAggConfigs }?: {
addToAggConfigs?: boolean | undefined;
}) => T
| |
+| [forceNow](./kibana-plugin-plugins-data-public.aggconfigs.forcenow.md) | | Date
| |
| [hierarchical](./kibana-plugin-plugins-data-public.aggconfigs.hierarchical.md) | | boolean
| |
| [indexPattern](./kibana-plugin-plugins-data-public.aggconfigs.indexpattern.md) | | IndexPattern
| |
| [timeFields](./kibana-plugin-plugins-data-public.aggconfigs.timefields.md) | | string[]
| |
@@ -43,8 +44,14 @@ export declare class AggConfigs
| [getRequestAggs()](./kibana-plugin-plugins-data-public.aggconfigs.getrequestaggs.md) | | |
| [getResponseAggById(id)](./kibana-plugin-plugins-data-public.aggconfigs.getresponseaggbyid.md) | | Find a response agg by it's id. This may be an agg in the aggConfigs, or one created specifically for a response value |
| [getResponseAggs()](./kibana-plugin-plugins-data-public.aggconfigs.getresponseaggs.md) | | Gets the AggConfigs (and possibly ResponseAggConfigs) that represent the values that will be produced when all aggs are run.With multi-value metric aggs it is possible for a single agg request to result in multiple agg values, which is why the length of a vis' responseValuesAggs may be different than the vis' aggs {array\[AggConfig\]} |
+| [getSearchSourceTimeFilter(forceNow)](./kibana-plugin-plugins-data-public.aggconfigs.getsearchsourcetimefilter.md) | | |
+| [getTimeShiftInterval()](./kibana-plugin-plugins-data-public.aggconfigs.gettimeshiftinterval.md) | | |
+| [getTimeShifts()](./kibana-plugin-plugins-data-public.aggconfigs.gettimeshifts.md) | | |
+| [hasTimeShifts()](./kibana-plugin-plugins-data-public.aggconfigs.hastimeshifts.md) | | |
| [jsonDataEquals(aggConfigs)](./kibana-plugin-plugins-data-public.aggconfigs.jsondataequals.md) | | Data-by-data comparison of this Aggregation Ignores the non-array indexes |
| [onSearchRequestStart(searchSource, options)](./kibana-plugin-plugins-data-public.aggconfigs.onsearchrequeststart.md) | | |
+| [postFlightTransform(response)](./kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md) | | |
+| [setForceNow(now)](./kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md) | | |
| [setTimeFields(timeFields)](./kibana-plugin-plugins-data-public.aggconfigs.settimefields.md) | | |
| [setTimeRange(timeRange)](./kibana-plugin-plugins-data-public.aggconfigs.settimerange.md) | | |
| [toDsl()](./kibana-plugin-plugins-data-public.aggconfigs.todsl.md) | | |
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md
new file mode 100644
index 00000000000000..b34fda40a30895
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md
@@ -0,0 +1,22 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [postFlightTransform](./kibana-plugin-plugins-data-public.aggconfigs.postflighttransform.md)
+
+## AggConfigs.postFlightTransform() method
+
+Signature:
+
+```typescript
+postFlightTransform(response: IEsSearchResponse): IEsSearchResponse;
+```
+
+## Parameters
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| response | IEsSearchResponse<any>
| |
+
+Returns:
+
+`IEsSearchResponse`
+
diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md
new file mode 100644
index 00000000000000..60a1bfe0872faf
--- /dev/null
+++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md
@@ -0,0 +1,22 @@
+
+
+[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggConfigs](./kibana-plugin-plugins-data-public.aggconfigs.md) > [setForceNow](./kibana-plugin-plugins-data-public.aggconfigs.setforcenow.md)
+
+## AggConfigs.setForceNow() method
+
+Signature:
+
+```typescript
+setForceNow(now: Date | undefined): void;
+```
+
+## Parameters
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| now | Date | undefined
| |
+
+Returns:
+
+`void`
+
diff --git a/src/plugins/data/common/search/aggs/agg_config.ts b/src/plugins/data/common/search/aggs/agg_config.ts
index 283d276a22904b..3c83b5bdf6084b 100644
--- a/src/plugins/data/common/search/aggs/agg_config.ts
+++ b/src/plugins/data/common/search/aggs/agg_config.ts
@@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
+import moment from 'moment';
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { Assign, Ensure } from '@kbn/utility-types';
@@ -20,6 +21,7 @@ import {
import { IAggType } from './agg_type';
import { writeParams } from './agg_params';
import { IAggConfigs } from './agg_configs';
+import { parseTimeShift } from './utils';
type State = string | number | boolean | null | undefined | SerializableState;
@@ -172,6 +174,31 @@ export class AggConfig {
return _.get(this.params, key);
}
+ hasTimeShift(): boolean {
+ return Boolean(this.getParam('timeShift'));
+ }
+
+ getTimeShift(): undefined | moment.Duration {
+ const rawTimeShift = this.getParam('timeShift');
+ if (!rawTimeShift) return undefined;
+ const parsedTimeShift = parseTimeShift(rawTimeShift);
+ if (parsedTimeShift === 'invalid') {
+ throw new Error(`could not parse time shift ${rawTimeShift}`);
+ }
+ if (parsedTimeShift === 'previous') {
+ const timeShiftInterval = this.aggConfigs.getTimeShiftInterval();
+ if (timeShiftInterval) {
+ return timeShiftInterval;
+ } else if (!this.aggConfigs.timeRange) {
+ return;
+ }
+ return moment.duration(
+ moment(this.aggConfigs.timeRange.to).diff(this.aggConfigs.timeRange.from)
+ );
+ }
+ return parsedTimeShift;
+ }
+
write(aggs?: IAggConfigs) {
return writeParams(this.type.params, this, aggs);
}
diff --git a/src/plugins/data/common/search/aggs/agg_configs.test.ts b/src/plugins/data/common/search/aggs/agg_configs.test.ts
index 28102544ae0553..72ea64791fa5b3 100644
--- a/src/plugins/data/common/search/aggs/agg_configs.test.ts
+++ b/src/plugins/data/common/search/aggs/agg_configs.test.ts
@@ -14,6 +14,7 @@ import { mockAggTypesRegistry } from './test_helpers';
import type { IndexPatternField } from '../../index_patterns';
import { IndexPattern } from '../../index_patterns/index_patterns/index_pattern';
import { stubIndexPattern, stubIndexPatternWithFields } from '../../../common/stubs';
+import { IEsSearchResponse } from '..';
describe('AggConfigs', () => {
let indexPattern: IndexPattern;
@@ -332,6 +333,109 @@ describe('AggConfigs', () => {
});
});
+ it('inserts a time split filters agg if there are multiple time shifts', () => {
+ const configStates = [
+ {
+ enabled: true,
+ type: 'terms',
+ schema: 'segment',
+ params: { field: 'clientip', size: 10 },
+ },
+ { enabled: true, type: 'avg', schema: 'metric', params: { field: 'bytes' } },
+ {
+ enabled: true,
+ type: 'sum',
+ schema: 'metric',
+ params: { field: 'bytes', timeShift: '1d' },
+ },
+ ];
+ indexPattern.fields.push({
+ name: 'timestamp',
+ type: 'date',
+ esTypes: ['date'],
+ aggregatable: true,
+ filterable: true,
+ searchable: true,
+ } as IndexPatternField);
+
+ const ac = new AggConfigs(indexPattern, configStates, { typesRegistry });
+ ac.timeFields = ['timestamp'];
+ ac.timeRange = {
+ from: '2021-05-05T00:00:00.000Z',
+ to: '2021-05-10T00:00:00.000Z',
+ };
+ const dsl = ac.toDsl();
+
+ const terms = ac.byName('terms')[0];
+ const avg = ac.byName('avg')[0];
+ const sum = ac.byName('sum')[0];
+
+ expect(dsl[terms.id].aggs.time_offset_split.filters.filters).toMatchInlineSnapshot(`
+ Object {
+ "0": Object {
+ "range": Object {
+ "timestamp": Object {
+ "gte": "2021-05-05T00:00:00.000Z",
+ "lte": "2021-05-10T00:00:00.000Z",
+ },
+ },
+ },
+ "86400000": Object {
+ "range": Object {
+ "timestamp": Object {
+ "gte": "2021-05-04T00:00:00.000Z",
+ "lte": "2021-05-09T00:00:00.000Z",
+ },
+ },
+ },
+ }
+ `);
+ expect(dsl[terms.id].aggs.time_offset_split.aggs).toHaveProperty(avg.id);
+ expect(dsl[terms.id].aggs.time_offset_split.aggs).toHaveProperty(sum.id);
+ });
+
+ it('does not insert a time split if there is a single time shift', () => {
+ const configStates = [
+ {
+ enabled: true,
+ type: 'terms',
+ schema: 'segment',
+ params: { field: 'clientip', size: 10 },
+ },
+ {
+ enabled: true,
+ type: 'avg',
+ schema: 'metric',
+ params: {
+ field: 'bytes',
+ timeShift: '1d',
+ },
+ },
+ {
+ enabled: true,
+ type: 'sum',
+ schema: 'metric',
+ params: { field: 'bytes', timeShift: '1d' },
+ },
+ ];
+
+ const ac = new AggConfigs(indexPattern, configStates, { typesRegistry });
+ ac.timeFields = ['timestamp'];
+ ac.timeRange = {
+ from: '2021-05-05T00:00:00.000Z',
+ to: '2021-05-10T00:00:00.000Z',
+ };
+ const dsl = ac.toDsl();
+
+ const terms = ac.byName('terms')[0];
+ const avg = ac.byName('avg')[0];
+ const sum = ac.byName('sum')[0];
+
+ expect(dsl[terms.id].aggs).not.toHaveProperty('time_offset_split');
+ expect(dsl[terms.id].aggs).toHaveProperty(avg.id);
+ expect(dsl[terms.id].aggs).toHaveProperty(sum.id);
+ });
+
it('writes multiple metric aggregations at every level if the vis is hierarchical', () => {
const configStates = [
{ enabled: true, type: 'terms', schema: 'segment', params: { field: 'bytes', orderBy: 1 } },
@@ -426,4 +530,246 @@ describe('AggConfigs', () => {
);
});
});
+
+ describe('#postFlightTransform', () => {
+ it('merges together splitted responses for multiple shifts', () => {
+ indexPattern = stubIndexPattern as IndexPattern;
+ indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField);
+ const configStates = [
+ {
+ enabled: true,
+ type: 'terms',
+ schema: 'segment',
+ params: { field: 'clientip', size: 10 },
+ },
+ {
+ enabled: true,
+ type: 'date_histogram',
+ schema: 'segment',
+ params: { field: '@timestamp', interval: '1d' },
+ },
+ {
+ enabled: true,
+ type: 'avg',
+ schema: 'metric',
+ params: {
+ field: 'bytes',
+ timeShift: '1d',
+ },
+ },
+ {
+ enabled: true,
+ type: 'sum',
+ schema: 'metric',
+ params: { field: 'bytes' },
+ },
+ ];
+
+ const ac = new AggConfigs(indexPattern, configStates, { typesRegistry });
+ ac.timeFields = ['@timestamp'];
+ ac.timeRange = {
+ from: '2021-05-05T00:00:00.000Z',
+ to: '2021-05-10T00:00:00.000Z',
+ };
+ // 1 terms bucket (A), with 2 date buckets (7th and 8th of May)
+ // the bucket keys of the shifted time range will be shifted forward
+ const response = {
+ rawResponse: {
+ aggregations: {
+ '1': {
+ buckets: [
+ {
+ key: 'A',
+ time_offset_split: {
+ buckets: {
+ '0': {
+ 2: {
+ buckets: [
+ {
+ // 2021-05-07
+ key: 1620345600000,
+ 3: {
+ value: 1.1,
+ },
+ 4: {
+ value: 2.2,
+ },
+ },
+ {
+ // 2021-05-08
+ key: 1620432000000,
+ doc_count: 26,
+ 3: {
+ value: 3.3,
+ },
+ 4: {
+ value: 4.4,
+ },
+ },
+ ],
+ },
+ },
+ '86400000': {
+ 2: {
+ buckets: [
+ {
+ // 2021-05-07
+ key: 1620345600000,
+ doc_count: 13,
+ 3: {
+ value: 5.5,
+ },
+ 4: {
+ value: 6.6,
+ },
+ },
+ {
+ // 2021-05-08
+ key: 1620432000000,
+ 3: {
+ value: 7.7,
+ },
+ 4: {
+ value: 8.8,
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ };
+ const mergedResponse = ac.postFlightTransform(
+ (response as unknown) as IEsSearchResponse
+ );
+ expect(mergedResponse.rawResponse).toEqual({
+ aggregations: {
+ '1': {
+ buckets: [
+ {
+ '2': {
+ buckets: [
+ {
+ '4': {
+ value: 2.2,
+ },
+ // 2021-05-07
+ key: 1620345600000,
+ },
+ {
+ '3': {
+ value: 5.5,
+ },
+ '4': {
+ value: 4.4,
+ },
+ doc_count: 26,
+ doc_count_86400000: 13,
+ // 2021-05-08
+ key: 1620432000000,
+ },
+ {
+ '3': {
+ value: 7.7,
+ },
+ // 2021-05-09
+ key: 1620518400000,
+ },
+ ],
+ },
+ key: 'A',
+ },
+ ],
+ },
+ },
+ });
+ });
+
+ it('shifts date histogram keys and renames doc_count properties for single shift', () => {
+ indexPattern = stubIndexPattern as IndexPattern;
+ indexPattern.fields.getByName = (name) => (({ name } as unknown) as IndexPatternField);
+ const configStates = [
+ {
+ enabled: true,
+ type: 'date_histogram',
+ schema: 'segment',
+ params: { field: '@timestamp', interval: '1d' },
+ },
+ {
+ enabled: true,
+ type: 'avg',
+ schema: 'metric',
+ params: {
+ field: 'bytes',
+ timeShift: '1d',
+ },
+ },
+ ];
+
+ const ac = new AggConfigs(indexPattern, configStates, { typesRegistry });
+ ac.timeFields = ['@timestamp'];
+ ac.timeRange = {
+ from: '2021-05-05T00:00:00.000Z',
+ to: '2021-05-10T00:00:00.000Z',
+ };
+ const response = {
+ rawResponse: {
+ aggregations: {
+ '1': {
+ buckets: [
+ {
+ // 2021-05-07
+ key: 1620345600000,
+ doc_count: 26,
+ 2: {
+ value: 1.1,
+ },
+ },
+ {
+ // 2021-05-08
+ key: 1620432000000,
+ doc_count: 27,
+ 2: {
+ value: 2.2,
+ },
+ },
+ ],
+ },
+ },
+ },
+ };
+ const mergedResponse = ac.postFlightTransform(
+ (response as unknown) as IEsSearchResponse
+ );
+ expect(mergedResponse.rawResponse).toEqual({
+ aggregations: {
+ '1': {
+ buckets: [
+ {
+ '2': {
+ value: 1.1,
+ },
+ doc_count_86400000: 26,
+ // 2021-05-08
+ key: 1620432000000,
+ },
+ {
+ '2': {
+ value: 2.2,
+ },
+ doc_count_86400000: 27,
+ // 2021-05-09
+ key: 1620518400000,
+ },
+ ],
+ },
+ },
+ });
+ });
+ });
});
diff --git a/src/plugins/data/common/search/aggs/agg_configs.ts b/src/plugins/data/common/search/aggs/agg_configs.ts
index 2932ef7325aed8..6f8a8d38a4a286 100644
--- a/src/plugins/data/common/search/aggs/agg_configs.ts
+++ b/src/plugins/data/common/search/aggs/agg_configs.ts
@@ -6,17 +6,26 @@
* Side Public License, v 1.
*/
-import _ from 'lodash';
+import moment from 'moment';
+import _, { cloneDeep } from 'lodash';
import { i18n } from '@kbn/i18n';
import { Assign } from '@kbn/utility-types';
-
-import { ISearchOptions, ISearchSource } from 'src/plugins/data/public';
+import { Aggregate, Bucket } from '@elastic/elasticsearch/api/types';
+
+import {
+ IEsSearchResponse,
+ ISearchOptions,
+ ISearchSource,
+ RangeFilter,
+} from 'src/plugins/data/public';
import { AggConfig, AggConfigSerialized, IAggConfig } from './agg_config';
import { IAggType } from './agg_type';
import { AggTypesRegistryStart } from './agg_types_registry';
import { AggGroupNames } from './agg_groups';
import { IndexPattern } from '../../index_patterns/index_patterns/index_pattern';
-import { TimeRange } from '../../../common';
+import { TimeRange, getTime, isRangeFilter } from '../../../common';
+import { IBucketAggConfig } from './buckets';
+import { insertTimeShiftSplit, mergeTimeShifts } from './utils/time_splits';
function removeParentAggs(obj: any) {
for (const prop in obj) {
@@ -48,6 +57,8 @@ export interface AggConfigsOptions {
export type CreateAggConfigParams = Assign;
+export type GenericBucket = Bucket & { [property: string]: Aggregate };
+
/**
* @name AggConfigs
*
@@ -66,6 +77,7 @@ export class AggConfigs {
public indexPattern: IndexPattern;
public timeRange?: TimeRange;
public timeFields?: string[];
+ public forceNow?: Date;
public hierarchical?: boolean = false;
private readonly typesRegistry: AggTypesRegistryStart;
@@ -92,6 +104,10 @@ export class AggConfigs {
this.timeFields = timeFields;
}
+ setForceNow(now: Date | undefined) {
+ this.forceNow = now;
+ }
+
setTimeRange(timeRange: TimeRange) {
this.timeRange = timeRange;
@@ -183,7 +199,13 @@ export class AggConfigs {
let dslLvlCursor: Record;
let nestedMetrics: Array<{ config: AggConfig; dsl: Record }> | [];
+ const timeShifts = this.getTimeShifts();
+ const hasMultipleTimeShifts = Object.keys(timeShifts).length > 1;
+
if (this.hierarchical) {
+ if (hasMultipleTimeShifts) {
+ throw new Error('Multiple time shifts not supported for hierarchical metrics');
+ }
// collect all metrics, and filter out the ones that we won't be copying
nestedMetrics = this.aggs
.filter(function (agg) {
@@ -196,52 +218,67 @@ export class AggConfigs {
};
});
}
- this.getRequestAggs()
- .filter((config: AggConfig) => !config.type.hasNoDsl)
- .forEach((config: AggConfig, i: number, list) => {
- if (!dslLvlCursor) {
- // start at the top level
- dslLvlCursor = dslTopLvl;
- } else {
- const prevConfig: AggConfig = list[i - 1];
- const prevDsl = dslLvlCursor[prevConfig.id];
+ const requestAggs = this.getRequestAggs();
+ const aggsWithDsl = requestAggs.filter((agg) => !agg.type.hasNoDsl).length;
+ const timeSplitIndex = this.getAll().findIndex(
+ (config) => 'splitForTimeShift' in config.type && config.type.splitForTimeShift(config, this)
+ );
- // advance the cursor and nest under the previous agg, or
- // put it on the same level if the previous agg doesn't accept
- // sub aggs
- dslLvlCursor = prevDsl?.aggs || dslLvlCursor;
- }
+ requestAggs.forEach((config: AggConfig, i: number, list) => {
+ if (!dslLvlCursor) {
+ // start at the top level
+ dslLvlCursor = dslTopLvl;
+ } else {
+ const prevConfig: AggConfig = list[i - 1];
+ const prevDsl = dslLvlCursor[prevConfig.id];
+
+ // advance the cursor and nest under the previous agg, or
+ // put it on the same level if the previous agg doesn't accept
+ // sub aggs
+ dslLvlCursor = prevDsl?.aggs || dslLvlCursor;
+ }
+
+ if (hasMultipleTimeShifts) {
+ dslLvlCursor = insertTimeShiftSplit(this, config, timeShifts, dslLvlCursor);
+ }
- const dsl = config.type.hasNoDslParams
- ? config.toDsl(this)
- : (dslLvlCursor[config.id] = config.toDsl(this));
- let subAggs: any;
+ if (config.type.hasNoDsl) {
+ return;
+ }
- parseParentAggs(dslLvlCursor, dsl);
+ const dsl = config.type.hasNoDslParams
+ ? config.toDsl(this)
+ : (dslLvlCursor[config.id] = config.toDsl(this));
+ let subAggs: any;
- if (config.type.type === AggGroupNames.Buckets && i < list.length - 1) {
- // buckets that are not the last item in the list accept sub-aggs
- subAggs = dsl.aggs || (dsl.aggs = {});
- }
+ parseParentAggs(dslLvlCursor, dsl);
- if (subAggs) {
- _.each(subAggs, (agg) => {
- parseParentAggs(subAggs, agg);
- });
- }
- if (subAggs && nestedMetrics) {
- nestedMetrics.forEach((agg: any) => {
- subAggs[agg.config.id] = agg.dsl;
- // if a nested metric agg has parent aggs, we have to add them to every level of the tree
- // to make sure "bucket_path" references in the nested metric agg itself are still working
- if (agg.dsl.parentAggs) {
- Object.entries(agg.dsl.parentAggs).forEach(([parentAggId, parentAgg]) => {
- subAggs[parentAggId] = parentAgg;
- });
- }
- });
- }
- });
+ if (
+ config.type.type === AggGroupNames.Buckets &&
+ (i < aggsWithDsl - 1 || timeSplitIndex > i)
+ ) {
+ // buckets that are not the last item in the list of dsl producing aggs or have a time split coming up accept sub-aggs
+ subAggs = dsl.aggs || (dsl.aggs = {});
+ }
+
+ if (subAggs) {
+ _.each(subAggs, (agg) => {
+ parseParentAggs(subAggs, agg);
+ });
+ }
+ if (subAggs && nestedMetrics) {
+ nestedMetrics.forEach((agg: any) => {
+ subAggs[agg.config.id] = agg.dsl;
+ // if a nested metric agg has parent aggs, we have to add them to every level of the tree
+ // to make sure "bucket_path" references in the nested metric agg itself are still working
+ if (agg.dsl.parentAggs) {
+ Object.entries(agg.dsl.parentAggs).forEach(([parentAggId, parentAgg]) => {
+ subAggs[parentAggId] = parentAgg;
+ });
+ }
+ });
+ }
+ });
removeParentAggs(dslTopLvl);
return dslTopLvl;
@@ -289,6 +326,104 @@ export class AggConfigs {
);
}
+ getTimeShifts(): Record {
+ const timeShifts: Record = {};
+ this.getAll()
+ .filter((agg) => agg.schema === 'metric')
+ .map((agg) => agg.getTimeShift())
+ .forEach((timeShift) => {
+ if (timeShift) {
+ timeShifts[String(timeShift.asMilliseconds())] = timeShift;
+ } else {
+ timeShifts[0] = moment.duration(0);
+ }
+ });
+ return timeShifts;
+ }
+
+ getTimeShiftInterval(): moment.Duration | undefined {
+ const splitAgg = (this.getAll().filter(
+ (agg) => agg.type.type === AggGroupNames.Buckets
+ ) as IBucketAggConfig[]).find((agg) => agg.type.splitForTimeShift(agg, this));
+ return splitAgg?.type.getTimeShiftInterval(splitAgg);
+ }
+
+ hasTimeShifts(): boolean {
+ return this.getAll().some((agg) => agg.hasTimeShift());
+ }
+
+ getSearchSourceTimeFilter(forceNow?: Date) {
+ if (!this.timeFields || !this.timeRange) {
+ return [];
+ }
+ const timeRange = this.timeRange;
+ const timeFields = this.timeFields;
+ const timeShifts = this.getTimeShifts();
+ if (!this.hasTimeShifts()) {
+ return this.timeFields
+ .map((fieldName) => getTime(this.indexPattern, timeRange, { fieldName, forceNow }))
+ .filter(isRangeFilter);
+ }
+ return [
+ {
+ meta: {
+ index: this.indexPattern?.id,
+ params: {},
+ alias: '',
+ disabled: false,
+ negate: false,
+ },
+ query: {
+ bool: {
+ should: Object.entries(timeShifts).map(([, shift]) => {
+ return {
+ bool: {
+ filter: timeFields
+ .map(
+ (fieldName) =>
+ [
+ getTime(this.indexPattern, timeRange, { fieldName, forceNow }),
+ fieldName,
+ ] as [RangeFilter | undefined, string]
+ )
+ .filter(([filter]) => isRangeFilter(filter))
+ .map(([filter, field]) => ({
+ range: {
+ [field]: {
+ gte: moment(filter?.range[field].gte).subtract(shift).toISOString(),
+ lte: moment(filter?.range[field].lte).subtract(shift).toISOString(),
+ },
+ },
+ })),
+ },
+ };
+ }),
+ minimum_should_match: 1,
+ },
+ },
+ },
+ ];
+ }
+
+ postFlightTransform(response: IEsSearchResponse) {
+ if (!this.hasTimeShifts()) {
+ return response;
+ }
+ const transformedRawResponse = cloneDeep(response.rawResponse);
+ if (!transformedRawResponse.aggregations) {
+ transformedRawResponse.aggregations = {
+ doc_count: response.rawResponse.hits?.total as Aggregate,
+ };
+ }
+ const aggCursor = transformedRawResponse.aggregations!;
+
+ mergeTimeShifts(this, aggCursor);
+ return {
+ ...response,
+ rawResponse: transformedRawResponse,
+ };
+ }
+
getRequestAggById(id: string) {
return this.aggs.find((agg: AggConfig) => agg.id === id);
}
diff --git a/src/plugins/data/common/search/aggs/agg_type.ts b/src/plugins/data/common/search/aggs/agg_type.ts
index f0f3912bf64fea..48ce54bbd61bdb 100644
--- a/src/plugins/data/common/search/aggs/agg_type.ts
+++ b/src/plugins/data/common/search/aggs/agg_type.ts
@@ -215,6 +215,10 @@ export class AggType<
return agg.id;
};
+ splitForTimeShift(agg: TAggConfig, aggs: IAggConfigs) {
+ return false;
+ }
+
/**
* Generic AggType Constructor
*
diff --git a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts
index 6230ae897b1702..372d487bcf7a39 100644
--- a/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts
+++ b/src/plugins/data/common/search/aggs/buckets/_terms_other_bucket_helper.ts
@@ -166,7 +166,7 @@ export const buildOtherBucketAgg = (
key: string
) => {
// make sure there are actually results for the buckets
- if (aggregations[aggId].buckets.length < 1) {
+ if (aggregations[aggId]?.buckets.length < 1) {
noAggBucketResults = true;
return;
}
diff --git a/src/plugins/data/common/search/aggs/buckets/bucket_agg_type.ts b/src/plugins/data/common/search/aggs/buckets/bucket_agg_type.ts
index e9ed3799b90cf2..d44e634a00fe6b 100644
--- a/src/plugins/data/common/search/aggs/buckets/bucket_agg_type.ts
+++ b/src/plugins/data/common/search/aggs/buckets/bucket_agg_type.ts
@@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
+import moment from 'moment';
import { IAggConfig } from '../agg_config';
-import { KBN_FIELD_TYPES } from '../../../../common';
+import { GenericBucket, IAggConfigs, KBN_FIELD_TYPES } from '../../../../common';
import { AggType, AggTypeConfig } from '../agg_type';
import { AggParamType } from '../param_types/agg';
@@ -26,6 +27,14 @@ const bucketType = 'buckets';
interface BucketAggTypeConfig
extends AggTypeConfig> {
getKey?: (bucket: any, key: any, agg: IAggConfig) => any;
+ getShiftedKey?: (
+ agg: TBucketAggConfig,
+ key: string | number,
+ timeShift: moment.Duration
+ ) => string | number;
+ orderBuckets?(agg: TBucketAggConfig, a: GenericBucket, b: GenericBucket): number;
+ splitForTimeShift?(agg: TBucketAggConfig, aggs: IAggConfigs): boolean;
+ getTimeShiftInterval?(agg: TBucketAggConfig): undefined | moment.Duration;
}
export class BucketAggType extends AggType<
@@ -35,6 +44,22 @@ export class BucketAggType any;
type = bucketType;
+ getShiftedKey(
+ agg: TBucketAggConfig,
+ key: string | number,
+ timeShift: moment.Duration
+ ): string | number {
+ return key;
+ }
+
+ getTimeShiftInterval(agg: TBucketAggConfig): undefined | moment.Duration {
+ return undefined;
+ }
+
+ orderBuckets(agg: TBucketAggConfig, a: GenericBucket, b: GenericBucket): number {
+ return Number(a.key) - Number(b.key);
+ }
+
constructor(config: BucketAggTypeConfig) {
super(config);
@@ -43,6 +68,22 @@ export class BucketAggType {
return key || bucket.key;
});
+
+ if (config.getShiftedKey) {
+ this.getShiftedKey = config.getShiftedKey;
+ }
+
+ if (config.orderBuckets) {
+ this.orderBuckets = config.orderBuckets;
+ }
+
+ if (config.getTimeShiftInterval) {
+ this.getTimeShiftInterval = config.getTimeShiftInterval;
+ }
+
+ if (config.splitForTimeShift) {
+ this.splitForTimeShift = config.splitForTimeShift;
+ }
}
}
diff --git a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts
index 4a83ae38d34db9..4cbf6562487b2c 100644
--- a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts
+++ b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts
@@ -135,6 +135,17 @@ export const getDateHistogramBucketAgg = ({
},
};
},
+ getShiftedKey(agg, key, timeShift) {
+ return moment(key).add(timeShift).valueOf();
+ },
+ splitForTimeShift(agg, aggs) {
+ return aggs.hasTimeShifts() && Boolean(aggs.timeFields?.includes(agg.fieldName()));
+ },
+ getTimeShiftInterval(agg) {
+ const { useNormalizedEsInterval } = agg.params;
+ const interval = agg.buckets.getInterval(useNormalizedEsInterval);
+ return interval;
+ },
params: [
{
name: 'field',
diff --git a/src/plugins/data/common/search/aggs/buckets/terms.ts b/src/plugins/data/common/search/aggs/buckets/terms.ts
index 1b876051d009b7..b9329bcb25af37 100644
--- a/src/plugins/data/common/search/aggs/buckets/terms.ts
+++ b/src/plugins/data/common/search/aggs/buckets/terms.ts
@@ -9,6 +9,7 @@
import { noop } from 'lodash';
import { i18n } from '@kbn/i18n';
+import moment from 'moment';
import { BucketAggType, IBucketAggConfig } from './bucket_agg_type';
import { BUCKET_TYPES } from './bucket_agg_types';
import { createFilterTerms } from './create_filter/terms';
@@ -179,6 +180,54 @@ export const getTermsBucketAgg = () =>
return;
}
+ if (
+ aggs?.hasTimeShifts() &&
+ Object.keys(aggs?.getTimeShifts()).length > 1 &&
+ aggs.timeRange
+ ) {
+ const shift = orderAgg.getTimeShift();
+ orderAgg = aggs.createAggConfig(
+ {
+ type: 'filtered_metric',
+ id: orderAgg.id,
+ params: {
+ customBucket: aggs
+ .createAggConfig(
+ {
+ type: 'filter',
+ id: 'shift',
+ params: {
+ filter: {
+ language: 'lucene',
+ query: {
+ range: {
+ [aggs.timeFields![0]]: {
+ gte: moment(aggs.timeRange.from)
+ .subtract(shift || 0)
+ .toISOString(),
+ lte: moment(aggs.timeRange.to)
+ .subtract(shift || 0)
+ .toISOString(),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ addToAggConfigs: false,
+ }
+ )
+ .serialize(),
+ customMetric: orderAgg.serialize(),
+ },
+ enabled: false,
+ },
+ {
+ addToAggConfigs: false,
+ }
+ );
+ }
if (orderAgg.type.name === 'count') {
order._count = dir;
return;
diff --git a/src/plugins/data/common/search/aggs/metrics/avg_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/avg_fn.test.ts
index 05a6e9eeff7d74..0b794617fb96ed 100644
--- a/src/plugins/data/common/search/aggs/metrics/avg_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/avg_fn.test.ts
@@ -27,6 +27,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "avg",
diff --git a/src/plugins/data/common/search/aggs/metrics/avg_fn.ts b/src/plugins/data/common/search/aggs/metrics/avg_fn.ts
index 253013238d10e4..e32de6cd0a83f3 100644
--- a/src/plugins/data/common/search/aggs/metrics/avg_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/avg_fn.ts
@@ -62,6 +62,13 @@ export const aggAvg = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.test.ts
index 33f20b9a40dc26..ac214c1a1591ce 100644
--- a/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.test.ts
@@ -29,6 +29,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"customMetric": undefined,
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "avg_bucket",
@@ -42,11 +43,13 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"customMetric": undefined,
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "avg_bucket",
},
"json": undefined,
+ "timeShift": undefined,
}
`);
});
diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.ts b/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.ts
index b79e57207ebd81..a980f6ac555a24 100644
--- a/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/bucket_avg_fn.ts
@@ -77,6 +77,13 @@ export const aggBucketAvg = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.test.ts
index 35b765ec0e075c..e6db7665a68ddc 100644
--- a/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.test.ts
@@ -29,6 +29,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"customMetric": undefined,
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "max_bucket",
@@ -42,11 +43,13 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"customMetric": undefined,
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "max_bucket",
},
"json": undefined,
+ "timeShift": undefined,
}
`);
});
diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.ts b/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.ts
index e12a592448334d..0d3e8a5e7f878e 100644
--- a/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/bucket_max_fn.ts
@@ -77,6 +77,13 @@ export const aggBucketMax = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.test.ts
index 49346036ce6492..22ec55506fe901 100644
--- a/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.test.ts
@@ -29,6 +29,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"customMetric": undefined,
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "min_bucket",
@@ -42,11 +43,13 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"customMetric": undefined,
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "min_bucket",
},
"json": undefined,
+ "timeShift": undefined,
}
`);
});
diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.ts b/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.ts
index ece5c07c6e5f86..3b6c32595909a9 100644
--- a/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/bucket_min_fn.ts
@@ -77,6 +77,13 @@ export const aggBucketMin = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.test.ts
index 0f5c84a477b06b..0e3370cec14e5d 100644
--- a/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.test.ts
@@ -29,6 +29,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"customMetric": undefined,
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "sum_bucket",
@@ -42,11 +43,13 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"customMetric": undefined,
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "sum_bucket",
},
"json": undefined,
+ "timeShift": undefined,
}
`);
});
diff --git a/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.ts b/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.ts
index 5fe0ee75bfe38a..ae3502bbc25883 100644
--- a/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/bucket_sum_fn.ts
@@ -77,6 +77,13 @@ export const aggBucketSum = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts
index 8b235edacb59a0..08d64e599d8a9e 100644
--- a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts
@@ -27,6 +27,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "cardinality",
diff --git a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts
index ee0f72e01e1def..89006761407f74 100644
--- a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts
@@ -67,6 +67,13 @@ export const aggCardinality = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/count.ts b/src/plugins/data/common/search/aggs/metrics/count.ts
index 8a10d7edb3f835..fac1751290f70d 100644
--- a/src/plugins/data/common/search/aggs/metrics/count.ts
+++ b/src/plugins/data/common/search/aggs/metrics/count.ts
@@ -31,7 +31,12 @@ export const getCountMetricAgg = () =>
};
},
getValue(agg, bucket) {
- return bucket.doc_count;
+ const timeShift = agg.getTimeShift();
+ if (!timeShift) {
+ return bucket.doc_count;
+ } else {
+ return bucket[`doc_count_${timeShift.asMilliseconds()}`];
+ }
},
isScalable() {
return true;
diff --git a/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts
index 047b9bbd8517f2..c6736c5b69f7d4 100644
--- a/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts
@@ -23,6 +23,7 @@ describe('agg_expression_functions', () => {
"id": undefined,
"params": Object {
"customLabel": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "count",
diff --git a/src/plugins/data/common/search/aggs/metrics/count_fn.ts b/src/plugins/data/common/search/aggs/metrics/count_fn.ts
index 40c87db57eedca..a3a4bcc16a3913 100644
--- a/src/plugins/data/common/search/aggs/metrics/count_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/count_fn.ts
@@ -54,6 +54,13 @@ export const aggCount = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.test.ts
index 5eb6d2b7804420..f311ab35a8d0df 100644
--- a/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.test.ts
@@ -29,6 +29,7 @@ describe('agg_expression_functions', () => {
"customMetric": undefined,
"json": undefined,
"metricAgg": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "cumulative_sum",
@@ -54,6 +55,7 @@ describe('agg_expression_functions', () => {
"customMetric": undefined,
"json": undefined,
"metricAgg": "sum",
+ "timeShift": undefined,
},
"schema": undefined,
"type": "cumulative_sum",
@@ -81,12 +83,14 @@ describe('agg_expression_functions', () => {
"customMetric": undefined,
"json": undefined,
"metricAgg": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "cumulative_sum",
},
"json": undefined,
"metricAgg": undefined,
+ "timeShift": undefined,
}
`);
});
diff --git a/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.ts b/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.ts
index cba4de1ad11aec..5cdbcfe8575853 100644
--- a/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/cumulative_sum_fn.ts
@@ -81,6 +81,13 @@ export const aggCumulativeSum = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/derivative_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/derivative_fn.test.ts
index 1eaca811a2481f..3e4fc838dd398a 100644
--- a/src/plugins/data/common/search/aggs/metrics/derivative_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/derivative_fn.test.ts
@@ -29,6 +29,7 @@ describe('agg_expression_functions', () => {
"customMetric": undefined,
"json": undefined,
"metricAgg": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "derivative",
@@ -54,6 +55,7 @@ describe('agg_expression_functions', () => {
"customMetric": undefined,
"json": undefined,
"metricAgg": "sum",
+ "timeShift": undefined,
},
"schema": undefined,
"type": "derivative",
@@ -81,12 +83,14 @@ describe('agg_expression_functions', () => {
"customMetric": undefined,
"json": undefined,
"metricAgg": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "derivative",
},
"json": undefined,
"metricAgg": undefined,
+ "timeShift": undefined,
}
`);
});
diff --git a/src/plugins/data/common/search/aggs/metrics/derivative_fn.ts b/src/plugins/data/common/search/aggs/metrics/derivative_fn.ts
index e27179c7209ade..8bfe808aede8e9 100644
--- a/src/plugins/data/common/search/aggs/metrics/derivative_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/derivative_fn.ts
@@ -81,6 +81,13 @@ export const aggDerivative = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts
index aa2417bbf84156..00f47d31b0398d 100644
--- a/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts
+++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts
@@ -42,7 +42,7 @@ export const getFilteredMetricAgg = () => {
getValue(agg, bucket) {
const customMetric = agg.getParam('customMetric');
const customBucket = agg.getParam('customBucket');
- return customMetric.getValue(bucket[customBucket.id]);
+ return bucket && bucket[customBucket.id] && customMetric.getValue(bucket[customBucket.id]);
},
getValueBucketPath(agg) {
const customBucket = agg.getParam('customBucket');
diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts
index 22e97fe18b604c..d1ce6ff4639035 100644
--- a/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.test.ts
@@ -28,6 +28,7 @@ describe('agg_expression_functions', () => {
"customBucket": undefined,
"customLabel": undefined,
"customMetric": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "filtered_metric",
@@ -40,10 +41,12 @@ describe('agg_expression_functions', () => {
"customBucket": undefined,
"customLabel": undefined,
"customMetric": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "filtered_metric",
},
+ "timeShift": undefined,
}
`);
});
diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts
index 6a7ff5fa5fd40e..0b3d3acd3a603f 100644
--- a/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric_fn.ts
@@ -72,6 +72,13 @@ export const aggFilteredMetric = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.test.ts
index c48233e84404c6..50b5f5b60376b6 100644
--- a/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.test.ts
@@ -27,6 +27,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "geo_bounds",
diff --git a/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.ts b/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.ts
index 19d2dabc843dd9..b2cfad1805b9f5 100644
--- a/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/geo_bounds_fn.ts
@@ -67,6 +67,13 @@ export const aggGeoBounds = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.test.ts
index e984df13527ca9..889ed29c63ee14 100644
--- a/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.test.ts
@@ -27,6 +27,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "geo_centroid",
diff --git a/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.ts b/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.ts
index 1cc11c345e9ba0..9215f7afb4c6d1 100644
--- a/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/geo_centroid_fn.ts
@@ -67,6 +67,13 @@ export const aggGeoCentroid = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/max_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/max_fn.test.ts
index d94e01927c851b..021c5aac69e102 100644
--- a/src/plugins/data/common/search/aggs/metrics/max_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/max_fn.test.ts
@@ -27,6 +27,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "max",
diff --git a/src/plugins/data/common/search/aggs/metrics/max_fn.ts b/src/plugins/data/common/search/aggs/metrics/max_fn.ts
index 7eac992680737d..7a1d8ad22fb7ed 100644
--- a/src/plugins/data/common/search/aggs/metrics/max_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/max_fn.ts
@@ -62,6 +62,13 @@ export const aggMax = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/median.ts b/src/plugins/data/common/search/aggs/metrics/median.ts
index bad4c7baf173f6..4fdb1ce6b7d81c 100644
--- a/src/plugins/data/common/search/aggs/metrics/median.ts
+++ b/src/plugins/data/common/search/aggs/metrics/median.ts
@@ -46,7 +46,7 @@ export const getMedianMetricAgg = () => {
{ name: 'percents', default: [50], shouldShow: () => false, serialize: () => undefined },
],
getValue(agg, bucket) {
- return bucket[agg.id].values['50.0'];
+ return bucket[agg.id]?.values['50.0'];
},
});
};
diff --git a/src/plugins/data/common/search/aggs/metrics/median_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/median_fn.test.ts
index e70520b743e179..7ff7f18cdbc02c 100644
--- a/src/plugins/data/common/search/aggs/metrics/median_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/median_fn.test.ts
@@ -27,6 +27,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "median",
diff --git a/src/plugins/data/common/search/aggs/metrics/median_fn.ts b/src/plugins/data/common/search/aggs/metrics/median_fn.ts
index 1c0afd81a63c4b..a9537e1f99ca41 100644
--- a/src/plugins/data/common/search/aggs/metrics/median_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/median_fn.ts
@@ -67,6 +67,13 @@ export const aggMedian = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts
index 3ebb771413665d..6ddb0fdd9410d4 100644
--- a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts
+++ b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts
@@ -11,7 +11,8 @@ import { AggType, AggTypeConfig } from '../agg_type';
import { AggParamType } from '../param_types/agg';
import { AggConfig } from '../agg_config';
import { METRIC_TYPES } from './metric_agg_types';
-import { FieldTypes } from '../param_types';
+import { BaseParamType, FieldTypes } from '../param_types';
+import { AggGroupNames } from '../agg_groups';
export interface IMetricAggConfig extends AggConfig {
type: InstanceType;
@@ -47,6 +48,14 @@ export class MetricAggType) {
super(config);
+ this.params.push(
+ new BaseParamType({
+ name: 'timeShift',
+ type: 'string',
+ write: () => {},
+ }) as MetricAggParam
+ );
+
this.getValue =
config.getValue ||
((agg, bucket) => {
@@ -69,6 +78,14 @@ export class MetricAggType false);
+
+ // split at this point if there are time shifts and this is the first metric
+ this.splitForTimeShift = (agg, aggs) =>
+ aggs.hasTimeShifts() &&
+ aggs.byType(AggGroupNames.Metrics)[0] === agg &&
+ !aggs
+ .byType(AggGroupNames.Buckets)
+ .some((bucketAgg) => bucketAgg.type.splitForTimeShift(bucketAgg, aggs));
}
}
diff --git a/src/plugins/data/common/search/aggs/metrics/min_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/min_fn.test.ts
index ea2d2cd23edaea..fee4b28882408d 100644
--- a/src/plugins/data/common/search/aggs/metrics/min_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/min_fn.test.ts
@@ -27,6 +27,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "min",
diff --git a/src/plugins/data/common/search/aggs/metrics/min_fn.ts b/src/plugins/data/common/search/aggs/metrics/min_fn.ts
index 6dfbac1ecb8b4d..a97834f310a49e 100644
--- a/src/plugins/data/common/search/aggs/metrics/min_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/min_fn.ts
@@ -62,6 +62,13 @@ export const aggMin = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.test.ts
index bde90c563afc11..645519a6683761 100644
--- a/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.test.ts
@@ -30,6 +30,7 @@ describe('agg_expression_functions', () => {
"json": undefined,
"metricAgg": undefined,
"script": undefined,
+ "timeShift": undefined,
"window": undefined,
},
"schema": undefined,
@@ -59,6 +60,7 @@ describe('agg_expression_functions', () => {
"json": undefined,
"metricAgg": "sum",
"script": "test",
+ "timeShift": undefined,
"window": 10,
},
"schema": undefined,
@@ -88,6 +90,7 @@ describe('agg_expression_functions', () => {
"json": undefined,
"metricAgg": undefined,
"script": undefined,
+ "timeShift": undefined,
"window": undefined,
},
"schema": undefined,
@@ -96,6 +99,7 @@ describe('agg_expression_functions', () => {
"json": undefined,
"metricAgg": undefined,
"script": undefined,
+ "timeShift": undefined,
"window": undefined,
}
`);
diff --git a/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.ts b/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.ts
index 667c585226a528..1637dad561c375 100644
--- a/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/moving_avg_fn.ts
@@ -94,6 +94,13 @@ export const aggMovingAvg = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.test.ts
index 9328597b24cfaa..873765374c80a6 100644
--- a/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.test.ts
@@ -27,6 +27,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
"values": undefined,
},
"schema": undefined,
@@ -51,6 +52,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
"values": Array [
1,
2,
diff --git a/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.ts b/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.ts
index 7929a01c0b5893..60a2882fcec581 100644
--- a/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/percentile_ranks_fn.ts
@@ -74,6 +74,13 @@ export const aggPercentileRanks = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/percentiles_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/percentiles_fn.test.ts
index 0d71df240d1226..468da036cea88f 100644
--- a/src/plugins/data/common/search/aggs/metrics/percentiles_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/percentiles_fn.test.ts
@@ -28,6 +28,7 @@ describe('agg_expression_functions', () => {
"field": "machine.os.keyword",
"json": undefined,
"percents": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "percentiles",
@@ -56,6 +57,7 @@ describe('agg_expression_functions', () => {
2,
3,
],
+ "timeShift": undefined,
},
"schema": undefined,
"type": "percentiles",
diff --git a/src/plugins/data/common/search/aggs/metrics/percentiles_fn.ts b/src/plugins/data/common/search/aggs/metrics/percentiles_fn.ts
index fa5120dfc3b97f..1a746a86cbcd57 100644
--- a/src/plugins/data/common/search/aggs/metrics/percentiles_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/percentiles_fn.ts
@@ -74,6 +74,13 @@ export const aggPercentiles = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.test.ts
index 065ef8021cbda2..aa73d5c44dd7fc 100644
--- a/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.test.ts
@@ -29,6 +29,7 @@ describe('agg_expression_functions', () => {
"customMetric": undefined,
"json": undefined,
"metricAgg": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "serial_diff",
@@ -54,6 +55,7 @@ describe('agg_expression_functions', () => {
"customMetric": undefined,
"json": undefined,
"metricAgg": "sum",
+ "timeShift": undefined,
},
"schema": undefined,
"type": "serial_diff",
@@ -81,12 +83,14 @@ describe('agg_expression_functions', () => {
"customMetric": undefined,
"json": undefined,
"metricAgg": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "serial_diff",
},
"json": undefined,
"metricAgg": undefined,
+ "timeShift": undefined,
}
`);
});
diff --git a/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.ts b/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.ts
index 925d85774c7ad6..8460cb891f1e4c 100644
--- a/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/serial_diff_fn.ts
@@ -81,6 +81,13 @@ export const aggSerialDiff = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/single_percentile_fn.ts b/src/plugins/data/common/search/aggs/metrics/single_percentile_fn.ts
index e7ef22c6faeee6..edf69031c31ace 100644
--- a/src/plugins/data/common/search/aggs/metrics/single_percentile_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/single_percentile_fn.ts
@@ -74,6 +74,13 @@ export const aggSinglePercentile = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.test.ts
index 9aaf82e65812b8..849987695dc7c4 100644
--- a/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.test.ts
@@ -27,6 +27,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "std_dev",
diff --git a/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.ts b/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.ts
index 80787a3383c6b5..c181065d2416e7 100644
--- a/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/std_deviation_fn.ts
@@ -67,6 +67,13 @@ export const aggStdDeviation = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts
index e19fc072e1cd98..f4d4fb5451dcda 100644
--- a/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts
@@ -27,6 +27,7 @@ describe('agg_expression_functions', () => {
"customLabel": undefined,
"field": "machine.os.keyword",
"json": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "sum",
diff --git a/src/plugins/data/common/search/aggs/metrics/sum_fn.ts b/src/plugins/data/common/search/aggs/metrics/sum_fn.ts
index d0175e0c8fafe4..d8e03d28bb12a7 100644
--- a/src/plugins/data/common/search/aggs/metrics/sum_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/sum_fn.ts
@@ -62,6 +62,13 @@ export const aggSum = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/metrics/top_hit_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/top_hit_fn.test.ts
index e9d6a619a9cd6b..2f8ef74b5c2f0c 100644
--- a/src/plugins/data/common/search/aggs/metrics/top_hit_fn.test.ts
+++ b/src/plugins/data/common/search/aggs/metrics/top_hit_fn.test.ts
@@ -32,6 +32,7 @@ describe('agg_expression_functions', () => {
"size": undefined,
"sortField": undefined,
"sortOrder": undefined,
+ "timeShift": undefined,
},
"schema": undefined,
"type": "top_hits",
@@ -64,6 +65,7 @@ describe('agg_expression_functions', () => {
"size": 6,
"sortField": "_score",
"sortOrder": "asc",
+ "timeShift": undefined,
},
"schema": "whatever",
"type": "top_hits",
diff --git a/src/plugins/data/common/search/aggs/metrics/top_hit_fn.ts b/src/plugins/data/common/search/aggs/metrics/top_hit_fn.ts
index 85b54f16954937..bc20f19253eec7 100644
--- a/src/plugins/data/common/search/aggs/metrics/top_hit_fn.ts
+++ b/src/plugins/data/common/search/aggs/metrics/top_hit_fn.ts
@@ -94,6 +94,13 @@ export const aggTopHit = (): FunctionDefinition => ({
defaultMessage: 'Represents a custom label for this aggregation',
}),
},
+ timeShift: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.metrics.timeShift.help', {
+ defaultMessage:
+ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.',
+ }),
+ },
},
fn: (input, args) => {
const { id, enabled, schema, ...rest } = args;
diff --git a/src/plugins/data/common/search/aggs/types.ts b/src/plugins/data/common/search/aggs/types.ts
index 675be2323b93e8..c0eb0c6c241a94 100644
--- a/src/plugins/data/common/search/aggs/types.ts
+++ b/src/plugins/data/common/search/aggs/types.ts
@@ -132,6 +132,7 @@ export type AggsStart = Assign {
+ const trimmedVal = val.trim();
+ if (trimmedVal === 'previous') {
+ return 'previous';
+ }
+ const [, amount, unit] = trimmedVal.match(/^(\d+)(\w)$/) || [];
+ const parsedAmount = Number(amount);
+ if (Number.isNaN(parsedAmount) || !allowedUnits.includes(unit as AllowedUnit)) {
+ return 'invalid';
+ }
+ return moment.duration(Number(amount), unit as AllowedUnit);
+};
diff --git a/src/plugins/data/common/search/aggs/utils/time_splits.ts b/src/plugins/data/common/search/aggs/utils/time_splits.ts
new file mode 100644
index 00000000000000..4ac47efaea3476
--- /dev/null
+++ b/src/plugins/data/common/search/aggs/utils/time_splits.ts
@@ -0,0 +1,447 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import moment from 'moment';
+import _, { isArray } from 'lodash';
+import {
+ Aggregate,
+ FiltersAggregate,
+ FiltersBucketItem,
+ MultiBucketAggregate,
+} from '@elastic/elasticsearch/api/types';
+
+import { AggGroupNames } from '../agg_groups';
+import { GenericBucket, AggConfigs, getTime, AggConfig } from '../../../../common';
+import { IBucketAggConfig } from '../buckets';
+
+/**
+ * This function will transform an ES response containg a time split (using a filters aggregation before the metrics or date histogram aggregation),
+ * merging together all branches for the different time ranges into a single response structure which can be tabified into a single table.
+ *
+ * If there is just a single time shift, there are no separate branches per time range - in this case only the date histogram keys are shifted by the
+ * configured amount of time.
+ *
+ * To do this, the following steps are taken:
+ * * Traverse the response tree, tracking the current agg config
+ * * Once the node which would contain the time split object is found, merge all separate time range buckets into a single layer of buckets of the parent agg
+ * * Recursively repeat this process for all nested sub-buckets
+ *
+ * Example input:
+ * ```
+ * "aggregations" : {
+ "product" : {
+ "buckets" : [
+ {
+ "key" : "Product A",
+ "doc_count" : 512,
+ "first_year" : {
+ "doc_count" : 418,
+ "overall_revenue" : {
+ "value" : 2163634.0
+ }
+ },
+ "time_offset_split" : {
+ "buckets" : {
+ "-1y" : {
+ "doc_count" : 420,
+ "year" : {
+ "buckets" : [
+ {
+ "key_as_string" : "2018",
+ "doc_count" : 81,
+ "revenue" : {
+ "value" : 505124.0
+ }
+ },
+ {
+ "key_as_string" : "2019",
+ "doc_count" : 65,
+ "revenue" : {
+ "value" : 363058.0
+ }
+ }
+ ]
+ }
+ },
+ "regular" : {
+ "doc_count" : 418,
+ "year" : {
+ "buckets" : [
+ {
+ "key_as_string" : "2019",
+ "doc_count" : 65,
+ "revenue" : {
+ "value" : 363058.0
+ }
+ },
+ {
+ "key_as_string" : "2020",
+ "doc_count" : 84,
+ "revenue" : {
+ "value" : 392924.0
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ {
+ "key" : "Product B",
+ "doc_count" : 248,
+ "first_year" : {
+ "doc_count" : 215,
+ "overall_revenue" : {
+ "value" : 1315547.0
+ }
+ },
+ "time_offset_split" : {
+ "buckets" : {
+ "-1y" : {
+ "doc_count" : 211,
+ "year" : {
+ "buckets" : [
+ {
+ "key_as_string" : "2018",
+ "key" : 1618963200000,
+ "doc_count" : 28,
+ "revenue" : {
+ "value" : 156543.0
+ }
+ },
+ // ...
+ * ```
+ *
+ * Example output:
+ * ```
+ * "aggregations" : {
+ "product" : {
+ "buckets" : [
+ {
+ "key" : "Product A",
+ "doc_count" : 512,
+ "first_year" : {
+ "doc_count" : 418,
+ "overall_revenue" : {
+ "value" : 2163634.0
+ }
+ },
+ "year" : {
+ "buckets" : [
+ {
+ "key_as_string" : "2019",
+ "doc_count" : 81,
+ "revenue_regular" : {
+ "value" : 505124.0
+ },
+ "revenue_-1y" : {
+ "value" : 302736.0
+ }
+ },
+ {
+ "key_as_string" : "2020",
+ "doc_count" : 78,
+ "revenue_regular" : {
+ "value" : 392924.0
+ },
+ "revenue_-1y" : {
+ "value" : 363058.0
+ },
+ }
+ // ...
+ * ```
+ *
+ *
+ * @param aggConfigs The agg configs instance
+ * @param aggCursor The root aggregations object from the response which will be mutated in place
+ */
+export function mergeTimeShifts(aggConfigs: AggConfigs, aggCursor: Record) {
+ const timeShifts = aggConfigs.getTimeShifts();
+ const hasMultipleTimeShifts = Object.keys(timeShifts).length > 1;
+ const requestAggs = aggConfigs.getRequestAggs();
+ const bucketAggs = aggConfigs.aggs.filter(
+ (agg) => agg.type.type === AggGroupNames.Buckets
+ ) as IBucketAggConfig[];
+ const mergeAggLevel = (
+ target: GenericBucket,
+ source: GenericBucket,
+ shift: moment.Duration,
+ aggIndex: number
+ ) => {
+ Object.entries(source).forEach(([key, val]) => {
+ // copy over doc count into special key
+ if (typeof val === 'number' && key === 'doc_count') {
+ if (shift.asMilliseconds() === 0) {
+ target.doc_count = val;
+ } else {
+ target[`doc_count_${shift.asMilliseconds()}`] = val;
+ }
+ } else if (typeof val !== 'object') {
+ // other meta keys not of interest
+ return;
+ } else {
+ // a sub-agg
+ const agg = requestAggs.find((requestAgg) => key.indexOf(requestAgg.id) === 0);
+ if (agg && agg.type.type === AggGroupNames.Metrics) {
+ const timeShift = agg.getTimeShift();
+ if (
+ (timeShift && timeShift.asMilliseconds() === shift.asMilliseconds()) ||
+ (shift.asMilliseconds() === 0 && !timeShift)
+ ) {
+ // this is a metric from the current time shift, copy it over
+ target[key] = source[key];
+ }
+ } else if (agg && agg === bucketAggs[aggIndex]) {
+ const bucketAgg = agg as IBucketAggConfig;
+ // expected next bucket sub agg
+ const subAggregate = val as Aggregate;
+ const buckets = ('buckets' in subAggregate ? subAggregate.buckets : undefined) as
+ | GenericBucket[]
+ | Record
+ | undefined;
+ if (!target[key]) {
+ // sub aggregate only exists in shifted branch, not in base branch - create dummy aggregate
+ // which will be filled with shifted data
+ target[key] = {
+ buckets: isArray(buckets) ? [] : {},
+ };
+ }
+ const baseSubAggregate = target[key] as Aggregate;
+ // only supported bucket formats in agg configs are array of buckets and record of buckets for filters
+ const baseBuckets = ('buckets' in baseSubAggregate
+ ? baseSubAggregate.buckets
+ : undefined) as GenericBucket[] | Record | undefined;
+ // merge
+ if (isArray(buckets) && isArray(baseBuckets)) {
+ const baseBucketMap: Record = {};
+ baseBuckets.forEach((bucket) => {
+ baseBucketMap[String(bucket.key)] = bucket;
+ });
+ buckets.forEach((bucket) => {
+ const bucketKey = bucketAgg.type.getShiftedKey(bucketAgg, bucket.key, shift);
+ // if a bucket is missing in the map, create an empty one
+ if (!baseBucketMap[bucketKey]) {
+ baseBucketMap[String(bucketKey)] = {
+ key: bucketKey,
+ } as GenericBucket;
+ }
+ mergeAggLevel(baseBucketMap[bucketKey], bucket, shift, aggIndex + 1);
+ });
+ (baseSubAggregate as MultiBucketAggregate).buckets = Object.values(
+ baseBucketMap
+ ).sort((a, b) => bucketAgg.type.orderBuckets(bucketAgg, a, b));
+ } else if (baseBuckets && buckets && !isArray(baseBuckets)) {
+ Object.entries(buckets).forEach(([bucketKey, bucket]) => {
+ // if a bucket is missing in the base response, create an empty one
+ if (!baseBuckets[bucketKey]) {
+ baseBuckets[bucketKey] = {} as GenericBucket;
+ }
+ mergeAggLevel(baseBuckets[bucketKey], bucket, shift, aggIndex + 1);
+ });
+ }
+ }
+ }
+ });
+ };
+ const transformTimeShift = (cursor: Record, aggIndex: number): undefined => {
+ const shouldSplit = aggConfigs.aggs[aggIndex].type.splitForTimeShift(
+ aggConfigs.aggs[aggIndex],
+ aggConfigs
+ );
+ if (shouldSplit) {
+ // multiple time shifts caused a filters agg in the tree we have to merge
+ if (hasMultipleTimeShifts && cursor.time_offset_split) {
+ const timeShiftedBuckets = (cursor.time_offset_split as FiltersAggregate).buckets as Record<
+ string,
+ FiltersBucketItem
+ >;
+ const subTree = {};
+ Object.entries(timeShifts).forEach(([key, shift]) => {
+ mergeAggLevel(
+ subTree as GenericBucket,
+ timeShiftedBuckets[key] as GenericBucket,
+ shift,
+ aggIndex
+ );
+ });
+
+ delete cursor.time_offset_split;
+ Object.assign(cursor, subTree);
+ } else {
+ // otherwise we have to "merge" a single level to shift all keys
+ const [[, shift]] = Object.entries(timeShifts);
+ const subTree = {};
+ mergeAggLevel(subTree, cursor, shift, aggIndex);
+ Object.assign(cursor, subTree);
+ }
+ return;
+ }
+ // recurse deeper into the response object
+ Object.keys(cursor).forEach((subAggId) => {
+ const subAgg = cursor[subAggId];
+ if (typeof subAgg !== 'object' || !('buckets' in subAgg)) {
+ return;
+ }
+ if (isArray(subAgg.buckets)) {
+ subAgg.buckets.forEach((bucket) => transformTimeShift(bucket, aggIndex + 1));
+ } else {
+ Object.values(subAgg.buckets).forEach((bucket) => transformTimeShift(bucket, aggIndex + 1));
+ }
+ });
+ };
+ transformTimeShift(aggCursor, 0);
+}
+
+/**
+ * Inserts a filters aggregation into the aggregation tree which splits buckets to fetch data for all time ranges
+ * configured in metric aggregations.
+ *
+ * The current agg config can implement `splitForTimeShift` to force insertion of the time split filters aggregation
+ * before the dsl of the agg config (date histogram and metrics aggregations do this)
+ *
+ * Example aggregation tree without time split:
+ * ```
+ * "aggs": {
+ "product": {
+ "terms": {
+ "field": "product",
+ "size": 3,
+ "order": { "overall_revenue": "desc" }
+ },
+ "aggs": {
+ "overall_revenue": {
+ "sum": {
+ "field": "sales"
+ }
+ },
+ "year": {
+ "date_histogram": {
+ "field": "timestamp",
+ "interval": "year"
+ },
+ "aggs": {
+ "revenue": {
+ "sum": {
+ "field": "sales"
+ }
+ }
+ }
+ // ...
+ * ```
+ *
+ * Same aggregation tree with inserted time split:
+ * ```
+ * "aggs": {
+ "product": {
+ "terms": {
+ "field": "product",
+ "size": 3,
+ "order": { "first_year>overall_revenue": "desc" }
+ },
+ "aggs": {
+ "first_year": {
+ "filter": {
+ "range": {
+ "timestamp": {
+ "gte": "2019",
+ "lte": "2020"
+ }
+ }
+ },
+ "aggs": {
+ "overall_revenue": {
+ "sum": {
+ "field": "sales"
+ }
+ }
+ }
+ },
+ "time_offset_split": {
+ "filters": {
+ "filters": {
+ "regular": {
+ "range": {
+ "timestamp": {
+ "gte": "2019",
+ "lte": "2020"
+ }
+ }
+ },
+ "-1y": {
+ "range": {
+ "timestamp": {
+ "gte": "2018",
+ "lte": "2019"
+ }
+ }
+ }
+ }
+ },
+ "aggs": {
+ "year": {
+ "date_histogram": {
+ "field": "timestamp",
+ "interval": "year"
+ },
+ "aggs": {
+ "revenue": {
+ "sum": {
+ "field": "sales"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ * ```
+ */
+export function insertTimeShiftSplit(
+ aggConfigs: AggConfigs,
+ config: AggConfig,
+ timeShifts: Record,
+ dslLvlCursor: Record
+) {
+ if ('splitForTimeShift' in config.type && !config.type.splitForTimeShift(config, aggConfigs)) {
+ return dslLvlCursor;
+ }
+ if (!aggConfigs.timeFields || aggConfigs.timeFields.length < 1) {
+ throw new Error('Time shift can only be used with configured time field');
+ }
+ if (!aggConfigs.timeRange) {
+ throw new Error('Time shift can only be used with configured time range');
+ }
+ const timeRange = aggConfigs.timeRange;
+ const filters: Record = {};
+ const timeField = aggConfigs.timeFields[0];
+ Object.entries(timeShifts).forEach(([key, shift]) => {
+ const timeFilter = getTime(aggConfigs.indexPattern, timeRange, {
+ fieldName: timeField,
+ forceNow: aggConfigs.forceNow,
+ });
+ if (timeFilter) {
+ filters[key] = {
+ range: {
+ [timeField]: {
+ gte: moment(timeFilter.range[timeField].gte).subtract(shift).toISOString(),
+ lte: moment(timeFilter.range[timeField].lte).subtract(shift).toISOString(),
+ },
+ },
+ };
+ }
+ });
+ dslLvlCursor.time_offset_split = {
+ filters: {
+ filters,
+ },
+ aggs: {},
+ };
+
+ return dslLvlCursor.time_offset_split.aggs;
+}
diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts
index 32775464d055f0..4f255cf4c244c6 100644
--- a/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts
+++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.test.ts
@@ -42,6 +42,7 @@ describe('esaggs expression function - public', () => {
toDsl: jest.fn().mockReturnValue({ aggs: {} }),
onSearchRequestStart: jest.fn(),
setTimeFields: jest.fn(),
+ setForceNow: jest.fn(),
} as unknown) as jest.Mocked,
filters: undefined,
indexPattern: ({ id: 'logstash-*' } as unknown) as jest.Mocked,
diff --git a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts
index d152ebf159a8ee..61193c52a5e74b 100644
--- a/src/plugins/data/common/search/expressions/esaggs/request_handler.ts
+++ b/src/plugins/data/common/search/expressions/esaggs/request_handler.ts
@@ -9,15 +9,7 @@
import { i18n } from '@kbn/i18n';
import { Adapters } from 'src/plugins/inspector/common';
-import {
- calculateBounds,
- Filter,
- getTime,
- IndexPattern,
- isRangeFilter,
- Query,
- TimeRange,
-} from '../../../../common';
+import { calculateBounds, Filter, IndexPattern, Query, TimeRange } from '../../../../common';
import { IAggConfigs } from '../../aggs';
import { ISearchStartSearchSource } from '../../search_source';
@@ -70,8 +62,15 @@ export const handleRequest = async ({
const timeFilterSearchSource = searchSource.createChild({ callParentStartHandlers: true });
const requestSearchSource = timeFilterSearchSource.createChild({ callParentStartHandlers: true });
+ // If timeFields have been specified, use the specified ones, otherwise use primary time field of index
+ // pattern if it's available.
+ const defaultTimeField = indexPattern?.getTimeField?.();
+ const defaultTimeFields = defaultTimeField ? [defaultTimeField.name] : [];
+ const allTimeFields = timeFields && timeFields.length > 0 ? timeFields : defaultTimeFields;
+
aggs.setTimeRange(timeRange as TimeRange);
- aggs.setTimeFields(timeFields);
+ aggs.setForceNow(forceNow);
+ aggs.setTimeFields(allTimeFields);
// For now we need to mirror the history of the passed search source, since
// the request inspector wouldn't work otherwise.
@@ -90,19 +89,11 @@ export const handleRequest = async ({
return aggs.onSearchRequestStart(paramSearchSource, options);
});
- // If timeFields have been specified, use the specified ones, otherwise use primary time field of index
- // pattern if it's available.
- const defaultTimeField = indexPattern?.getTimeField?.();
- const defaultTimeFields = defaultTimeField ? [defaultTimeField.name] : [];
- const allTimeFields = timeFields && timeFields.length > 0 ? timeFields : defaultTimeFields;
-
// If a timeRange has been specified and we had at least one timeField available, create range
// filters for that those time fields
if (timeRange && allTimeFields.length > 0) {
timeFilterSearchSource.setField('filter', () => {
- return allTimeFields
- .map((fieldName) => getTime(indexPattern, timeRange, { fieldName, forceNow }))
- .filter(isRangeFilter);
+ return aggs.getSearchSourceTimeFilter(forceNow);
});
}
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 f35d2d47f1bf47..7633382bb87631 100644
--- a/src/plugins/data/common/search/search_source/search_source.ts
+++ b/src/plugins/data/common/search/search_source/search_source.ts
@@ -75,7 +75,13 @@ import { estypes } from '@elastic/elasticsearch';
import { normalizeSortRequest } from './normalize_sort_request';
import { fieldWildcardFilter } from '../../../../kibana_utils/common';
import { IIndexPattern, IndexPattern, IndexPatternField } from '../../index_patterns';
-import { AggConfigs, ES_SEARCH_STRATEGY, ISearchGeneric, ISearchOptions } from '../..';
+import {
+ AggConfigs,
+ ES_SEARCH_STRATEGY,
+ IEsSearchResponse,
+ ISearchGeneric,
+ ISearchOptions,
+} from '../..';
import type {
ISearchSource,
SearchFieldValue,
@@ -414,6 +420,15 @@ export class SearchSource {
}
}
+ private postFlightTransform(response: IEsSearchResponse) {
+ const aggs = this.getField('aggs');
+ if (aggs instanceof AggConfigs) {
+ return aggs.postFlightTransform(response);
+ } else {
+ return response;
+ }
+ }
+
private async fetchOthers(response: estypes.SearchResponse, options: ISearchOptions) {
const aggs = this.getField('aggs');
if (aggs instanceof AggConfigs) {
@@ -451,24 +466,26 @@ export class SearchSource {
if (isErrorResponse(response)) {
obs.error(response);
} else if (isPartialResponse(response)) {
- obs.next(response);
+ obs.next(this.postFlightTransform(response));
} else {
if (!this.hasPostFlightRequests()) {
- obs.next(response);
+ obs.next(this.postFlightTransform(response));
obs.complete();
} else {
// Treat the complete response as partial, then run the postFlightRequests.
obs.next({
- ...response,
+ ...this.postFlightTransform(response),
isPartial: true,
isRunning: true,
});
const sub = from(this.fetchOthers(response.rawResponse, options)).subscribe({
next: (responseWithOther) => {
- obs.next({
- ...response,
- rawResponse: responseWithOther,
- });
+ obs.next(
+ this.postFlightTransform({
+ ...response,
+ rawResponse: responseWithOther!,
+ })
+ );
},
error: (e) => {
obs.error(e);
diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts
index 4a8972d4384c23..a4d9551da75d50 100644
--- a/src/plugins/data/common/search/tabify/tabify.ts
+++ b/src/plugins/data/common/search/tabify/tabify.ts
@@ -139,7 +139,7 @@ export function tabifyAggResponse(
const write = new TabbedAggResponseWriter(aggConfigs, respOpts || {});
const topLevelBucket: AggResponseBucket = {
...esResponse.aggregations,
- doc_count: esResponse.hits?.total,
+ doc_count: esResponse.aggregations?.doc_count || esResponse.hits?.total,
};
collectBucket(aggConfigs, write, topLevelBucket, '', 1);
diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md
index be6e489b17290d..9f5c2ef5fad3df 100644
--- a/src/plugins/data/public/public.api.md
+++ b/src/plugins/data/public/public.api.md
@@ -7,11 +7,13 @@
import { $Values } from '@kbn/utility-types';
import { Action } from 'history';
import { Adapters as Adapters_2 } from 'src/plugins/inspector/common';
+import { Aggregate } from '@elastic/elasticsearch/api/types';
import { ApiResponse } from '@elastic/elasticsearch/lib/Transport';
import { ApplicationStart } from 'kibana/public';
import { Assign } from '@kbn/utility-types';
import { BfetchPublicSetup } from 'src/plugins/bfetch/public';
import Boom from '@hapi/boom';
+import { Bucket } from '@elastic/elasticsearch/api/types';
import { ConfigDeprecationProvider } from '@kbn/config';
import { CoreSetup } from 'src/core/public';
import { CoreSetup as CoreSetup_2 } from 'kibana/public';
@@ -46,6 +48,7 @@ import { Href } from 'history';
import { HttpSetup } from 'kibana/public';
import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public';
import { IconType } from '@elastic/eui';
+import { IEsSearchResponse as IEsSearchResponse_2 } from 'src/plugins/data/public';
import { IncomingHttpHeaders } from 'http';
import { InjectedIntl } from '@kbn/i18n/react';
import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public';
@@ -74,6 +77,7 @@ import * as PropTypes from 'prop-types';
import { PublicContract } from '@kbn/utility-types';
import { PublicMethodsOf } from '@kbn/utility-types';
import { PublicUiSettingsParams } from 'src/core/server/types';
+import { RangeFilter as RangeFilter_2 } from 'src/plugins/data/public';
import React from 'react';
import * as React_3 from 'react';
import { RecursiveReadonly } from '@kbn/utility-types';
@@ -152,9 +156,13 @@ export class AggConfig {
// (undocumented)
getTimeRange(): import("../../../public").TimeRange | undefined;
// (undocumented)
+ getTimeShift(): undefined | moment.Duration;
+ // (undocumented)
getValue(bucket: any): any;
getValueBucketPath(): string;
// (undocumented)
+ hasTimeShift(): boolean;
+ // (undocumented)
id: string;
// (undocumented)
isFilterable(): boolean;
@@ -245,6 +253,8 @@ export class AggConfigs {
addToAggConfigs?: boolean | undefined;
}) => T;
// (undocumented)
+ forceNow?: Date;
+ // (undocumented)
getAll(): AggConfig[];
// (undocumented)
getRequestAggById(id: string): AggConfig | undefined;
@@ -253,6 +263,39 @@ export class AggConfigs {
getResponseAggById(id: string): AggConfig | undefined;
getResponseAggs(): AggConfig[];
// (undocumented)
+ getSearchSourceTimeFilter(forceNow?: Date): RangeFilter_2[] | {
+ meta: {
+ index: string | undefined;
+ params: {};
+ alias: string;
+ disabled: boolean;
+ negate: boolean;
+ };
+ query: {
+ bool: {
+ should: {
+ bool: {
+ filter: {
+ range: {
+ [x: string]: {
+ gte: string;
+ lte: string;
+ };
+ };
+ }[];
+ };
+ }[];
+ minimum_should_match: number;
+ };
+ };
+ }[];
+ // (undocumented)
+ getTimeShiftInterval(): moment.Duration | undefined;
+ // (undocumented)
+ getTimeShifts(): Record;
+ // (undocumented)
+ hasTimeShifts(): boolean;
+ // (undocumented)
hierarchical?: boolean;
// (undocumented)
indexPattern: IndexPattern;
@@ -260,6 +303,10 @@ export class AggConfigs {
// (undocumented)
onSearchRequestStart(searchSource: ISearchSource_2, options?: ISearchOptions_2): Promise<[unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown]>;
// (undocumented)
+ postFlightTransform(response: IEsSearchResponse_2): IEsSearchResponse_2;
+ // (undocumented)
+ setForceNow(now: Date | undefined): void;
+ // (undocumented)
setTimeFields(timeFields: string[] | undefined): void;
// (undocumented)
setTimeRange(timeRange: TimeRange): void;
diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md
index 8ec412e69d4a10..f57ba274881033 100644
--- a/src/plugins/data/server/server.api.md
+++ b/src/plugins/data/server/server.api.md
@@ -6,8 +6,10 @@
import { $Values } from '@kbn/utility-types';
import { Adapters } from 'src/plugins/inspector/common';
+import { Aggregate } from '@elastic/elasticsearch/api/types';
import { Assign } from '@kbn/utility-types';
import { BfetchServerSetup } from 'src/plugins/bfetch/server';
+import { Bucket } from '@elastic/elasticsearch/api/types';
import { ConfigDeprecationProvider } from '@kbn/config';
import { CoreSetup } from 'src/core/server';
import { CoreSetup as CoreSetup_2 } from 'kibana/server';
@@ -32,6 +34,7 @@ import { ExpressionsServerSetup } from 'src/plugins/expressions/server';
import { ExpressionValueBoxed } from 'src/plugins/expressions/common';
import { FormatFactory as FormatFactory_2 } from 'src/plugins/data/common/field_formats/utils';
import { IAggConfigs as IAggConfigs_2 } from 'src/plugins/data/public';
+import { IEsSearchResponse as IEsSearchResponse_2 } from 'src/plugins/data/public';
import { IScopedClusterClient } from 'src/core/server';
import { ISearchOptions as ISearchOptions_2 } from 'src/plugins/data/public';
import { ISearchSource } from 'src/plugins/data/public';
@@ -52,6 +55,7 @@ import { Plugin as Plugin_2 } from 'src/core/server';
import { Plugin as Plugin_3 } from 'kibana/server';
import { PluginInitializerContext as PluginInitializerContext_2 } from 'src/core/server';
import { PublicMethodsOf } from '@kbn/utility-types';
+import { RangeFilter } from 'src/plugins/data/public';
import { RecursiveReadonly } from '@kbn/utility-types';
import { RequestAdapter } from 'src/plugins/inspector/common';
import { RequestHandlerContext } from 'src/core/server';
diff --git a/src/plugins/vis_default_editor/public/components/agg_params_helper.ts b/src/plugins/vis_default_editor/public/components/agg_params_helper.ts
index fcd97de56fc655..55db880a839322 100644
--- a/src/plugins/vis_default_editor/public/components/agg_params_helper.ts
+++ b/src/plugins/vis_default_editor/public/components/agg_params_helper.ts
@@ -72,6 +72,9 @@ function getAggParamsToRender({
if (hideCustomLabel && param.name === 'customLabel') {
return;
}
+ if (param.name === 'timeShift') {
+ return;
+ }
// if field param exists, compute allowed fields
if (param.type === 'field') {
let availableFields: IndexPatternField[] = (param as IFieldParamType).getAvailableFields(agg);
diff --git a/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts b/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts
new file mode 100644
index 00000000000000..c750602f735bd9
--- /dev/null
+++ b/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts
@@ -0,0 +1,371 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import expect from '@kbn/expect';
+import { Datatable } from 'src/plugins/expressions';
+import { ExpectExpression, expectExpressionProvider } from './helpers';
+import { FtrProviderContext } from '../../../functional/ftr_provider_context';
+
+function getCell(esaggsResult: any, row: number, column: number): unknown | undefined {
+ const columnId = esaggsResult?.columns[column]?.id;
+ if (!columnId) {
+ return;
+ }
+ return esaggsResult?.rows[row]?.[columnId];
+}
+
+function checkShift(rows: Datatable['rows'], columns: Datatable['columns'], metricIndex = 1) {
+ rows.shift();
+ rows.pop();
+ rows.forEach((_, index) => {
+ if (index < rows.length - 1) {
+ expect(getCell({ rows, columns }, index, metricIndex + 1)).to.be(
+ getCell({ rows, columns }, index + 1, metricIndex)
+ );
+ }
+ });
+}
+
+export default function ({
+ getService,
+ updateBaselines,
+}: FtrProviderContext & { updateBaselines: boolean }) {
+ let expectExpression: ExpectExpression;
+
+ describe('esaggs timeshift tests', () => {
+ before(() => {
+ expectExpression = expectExpressionProvider({ getService, updateBaselines });
+ });
+
+ const timeRange = {
+ from: '2015-09-21T00:00:00Z',
+ to: '2015-09-22T00:00:00Z',
+ };
+
+ it('shifts single metric', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggCount id="1" enabled=true schema="metric" timeShift="1d"}
+ `;
+ const result = await expectExpression('esaggs_shift_single_metric', expression).getResponse();
+ expect(getCell(result, 0, 0)).to.be(4763);
+ });
+
+ it('shifts multiple metrics', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggCount id="1" enabled=true schema="metric" timeShift="12h"}
+ aggs={aggCount id="2" enabled=true schema="metric" timeShift="1d"}
+ aggs={aggCount id="3" enabled=true schema="metric"}
+ `;
+ const result = await expectExpression('esaggs_shift_multi_metric', expression).getResponse();
+ expect(getCell(result, 0, 0)).to.be(4629);
+ expect(getCell(result, 0, 1)).to.be(4763);
+ expect(getCell(result, 0, 2)).to.be(4618);
+ });
+
+ it('shifts single percentile', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggSinglePercentile id="1" enabled=true schema="metric" field="bytes" percentile=95}
+ aggs={aggSinglePercentile id="2" enabled=true schema="metric" field="bytes" percentile=95 timeShift="1d"}
+ `;
+ const result = await expectExpression(
+ 'esaggs_shift_single_percentile',
+ expression
+ ).getResponse();
+ // percentile is not stable
+ expect(getCell(result, 0, 0)).to.be.within(10000, 20000);
+ expect(getCell(result, 0, 1)).to.be.within(10000, 20000);
+ });
+
+ it('shifts multiple percentiles', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggPercentiles id="1" enabled=true schema="metric" field="bytes" percents=5 percents=95}
+ aggs={aggPercentiles id="2" enabled=true schema="metric" field="bytes" percents=5 percents=95 timeShift="1d"}
+ `;
+ const result = await expectExpression(
+ 'esaggs_shift_multi_percentile',
+ expression
+ ).getResponse();
+ // percentile is not stable
+ expect(getCell(result, 0, 0)).to.be.within(100, 1000);
+ expect(getCell(result, 0, 1)).to.be.within(10000, 20000);
+ expect(getCell(result, 0, 2)).to.be.within(100, 1000);
+ expect(getCell(result, 0, 3)).to.be.within(10000, 20000);
+ });
+
+ it('shifts date histogram', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggDateHistogram id="1" enabled=true schema="bucket" field="@timestamp" interval="1h"}
+ aggs={aggAvg id="2" field="bytes" enabled=true schema="metric" timeShift="1h"}
+ aggs={aggAvg id="3" field="bytes" enabled=true schema="metric"}
+ `;
+ const result: Datatable = await expectExpression(
+ 'esaggs_shift_date_histogram',
+ expression
+ ).getResponse();
+ expect(result.rows.length).to.be(25);
+ checkShift(result.rows, result.columns);
+ });
+
+ it('shifts filtered metrics', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggDateHistogram id="1" enabled=true schema="bucket" field="@timestamp" interval="1h"}
+ aggs={aggFilteredMetric
+ id="2"
+ customBucket={aggFilter
+ id="2-filter"
+ enabled=true
+ schema="bucket"
+ filter='{"language":"kuery","query":"geo.src:US"}'
+ }
+ customMetric={aggAvg id="3"
+ field="bytes"
+ enabled=true
+ schema="metric"
+ }
+ enabled=true
+ schema="metric"
+ timeShift="1h"
+ }
+ aggs={aggFilteredMetric
+ id="4"
+ customBucket={aggFilter
+ id="4-filter"
+ enabled=true
+ schema="bucket"
+ filter='{"language":"kuery","query":"geo.src:US"}'
+ }
+ customMetric={aggAvg id="5"
+ field="bytes"
+ enabled=true
+ schema="metric"
+ }
+ enabled=true
+ schema="metric"
+ }
+ `;
+ const result: Datatable = await expectExpression(
+ 'esaggs_shift_filtered_metrics',
+ expression
+ ).getResponse();
+ expect(result.rows.length).to.be(25);
+ checkShift(result.rows, result.columns);
+ });
+
+ it('shifts terms', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggTerms id="1" field="geo.src" size="3" enabled=true schema="bucket" orderAgg={aggCount id="order" enabled=true schema="metric"} otherBucket=true}
+ aggs={aggAvg id="2" field="bytes" enabled=true schema="metric" timeShift="1d"}
+ aggs={aggAvg id="3" field="bytes" enabled=true schema="metric"}
+ `;
+ const result: Datatable = await expectExpression(
+ 'esaggs_shift_terms',
+ expression
+ ).getResponse();
+ expect(result.rows).to.eql([
+ {
+ 'col-0-1': 'CN',
+ 'col-1-2': 40,
+ 'col-2-3': 5806.404352806415,
+ },
+ {
+ 'col-0-1': 'IN',
+ 'col-1-2': 7901,
+ 'col-2-3': 5838.315923566879,
+ },
+ {
+ 'col-0-1': 'US',
+ 'col-1-2': 7440,
+ 'col-2-3': 5614.142857142857,
+ },
+ {
+ 'col-0-1': '__other__',
+ 'col-1-2': 5766.575645756458,
+ 'col-2-3': 5742.1265576323985,
+ },
+ ]);
+ });
+
+ it('shifts filters', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggFilters id="1" filters='[{"input":{"query":"geo.src:\\"US\\" ","language":"kuery"},"label":""},{"input":{"query":"geo.src: \\"CN\\"","language":"kuery"},"label":""}]'}
+ aggs={aggFilters id="2" filters='[{"input":{"query":"geo.dest:\\"US\\" ","language":"kuery"},"label":""},{"input":{"query":"geo.dest: \\"CN\\"","language":"kuery"},"label":""}]'}
+ aggs={aggAvg id="3" field="bytes" enabled=true schema="metric" timeShift="2h"}
+ aggs={aggAvg id="4" field="bytes" enabled=true schema="metric"}
+ `;
+ const result: Datatable = await expectExpression(
+ 'esaggs_shift_filters',
+ expression
+ ).getResponse();
+ expect(result.rows).to.eql([
+ {
+ 'col-0-1': 'geo.src:"US" ',
+ 'col-1-2': 'geo.dest:"US" ',
+ 'col-2-3': 5956.9,
+ 'col-3-4': 5956.9,
+ },
+ {
+ 'col-0-1': 'geo.src:"US" ',
+ 'col-1-2': 'geo.dest: "CN"',
+ 'col-2-3': 5127.854838709677,
+ 'col-3-4': 5085.746031746032,
+ },
+ {
+ 'col-0-1': 'geo.src: "CN"',
+ 'col-1-2': 'geo.dest:"US" ',
+ 'col-2-3': 5648.25,
+ 'col-3-4': 5643.793650793651,
+ },
+ {
+ 'col-0-1': 'geo.src: "CN"',
+ 'col-1-2': 'geo.dest: "CN"',
+ 'col-2-3': 5842.858823529412,
+ 'col-3-4': 5842.858823529412,
+ },
+ ]);
+ });
+
+ it('shifts histogram', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggHistogram id="1" field="bytes" interval=5000 enabled=true schema="bucket"}
+ aggs={aggCount id="2" enabled=true schema="metric"}
+ aggs={aggCount id="3" enabled=true schema="metric" timeShift="6h"}
+ `;
+ const result: Datatable = await expectExpression(
+ 'esaggs_shift_histogram',
+ expression
+ ).getResponse();
+ expect(result.rows).to.eql([
+ {
+ 'col-0-1': 0,
+ 'col-1-2': 2020,
+ 'col-2-3': 2036,
+ },
+ {
+ 'col-0-1': 5000,
+ 'col-1-2': 2360,
+ 'col-2-3': 2358,
+ },
+ {
+ 'col-0-1': 10000,
+ 'col-1-2': 126,
+ 'col-2-3': 127,
+ },
+ {
+ 'col-0-1': 15000,
+ 'col-1-2': 112,
+ 'col-2-3': 108,
+ },
+ ]);
+ });
+
+ it('shifts sibling pipeline aggs', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggBucketSum id="1" enabled=true schema="metric" customBucket={aggTerms id="2" enabled="true" schema="bucket" field="geo.src" size="3"} customMetric={aggCount id="4" enabled="true" schema="metric"}}
+ aggs={aggBucketSum id="5" enabled=true schema="metric" timeShift="1d" customBucket={aggTerms id="6" enabled="true" schema="bucket" field="geo.src" size="3"} customMetric={aggCount id="7" enabled="true" schema="metric"}}
+ `;
+ const result: Datatable = await expectExpression(
+ 'esaggs_shift_sibling_pipeline_aggs',
+ expression
+ ).getResponse();
+ expect(getCell(result, 0, 0)).to.be(2050);
+ expect(getCell(result, 0, 1)).to.be(2053);
+ });
+
+ it('shifts parent pipeline aggs', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'}
+ aggs={aggDateHistogram id="1" enabled=true schema="bucket" field="@timestamp" interval="3h" min_doc_count=0}
+ aggs={aggMovingAvg id="2" enabled=true schema="metric" metricAgg="custom" window=5 script="MovingFunctions.unweightedAvg(values)" timeShift="3h" customMetric={aggCount id="2-metric" enabled="true" schema="metric"}}
+ `;
+ const result: Datatable = await expectExpression(
+ 'esaggs_shift_parent_pipeline_aggs',
+ expression
+ ).getResponse();
+ expect(result.rows).to.eql([
+ {
+ 'col-0-1': 1442791800000,
+ 'col-1-2': null,
+ },
+ {
+ 'col-0-1': 1442802600000,
+ 'col-1-2': 30,
+ },
+ {
+ 'col-0-1': 1442813400000,
+ 'col-1-2': 30.5,
+ },
+ {
+ 'col-0-1': 1442824200000,
+ 'col-1-2': 69.66666666666667,
+ },
+ {
+ 'col-0-1': 1442835000000,
+ 'col-1-2': 198.5,
+ },
+ {
+ 'col-0-1': 1442845800000,
+ 'col-1-2': 415.6,
+ },
+ {
+ 'col-0-1': 1442856600000,
+ 'col-1-2': 702.2,
+ },
+ {
+ 'col-0-1': 1442867400000,
+ 'col-1-2': 859.8,
+ },
+ {
+ 'col-0-1': 1442878200000,
+ 'col-1-2': 878.4,
+ },
+ ]);
+ });
+
+ it('metrics at all levels should work for single shift', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'} metricsAtAllLevels=true
+ aggs={aggCount id="1" enabled=true schema="metric" timeShift="1d"}
+ `;
+ const result = await expectExpression('esaggs_shift_single_metric', expression).getResponse();
+ expect(getCell(result, 0, 0)).to.be(4763);
+ });
+
+ it('metrics at all levels should fail for multiple shifts', async () => {
+ const expression = `
+ kibana_context timeRange={timerange from='${timeRange.from}' to='${timeRange.to}'}
+ | esaggs index={indexPatternLoad id='logstash-*'} metricsAtAllLevels=true
+ aggs={aggCount id="1" enabled=true schema="metric" timeShift="1d"}
+ aggs={aggCount id="2" enabled=true schema="metric"}
+ `;
+ const result = await expectExpression('esaggs_shift_single_metric', expression).getResponse();
+ expect(result.type).to.be('error');
+ });
+ });
+}
diff --git a/test/interpreter_functional/test_suites/run_pipeline/index.ts b/test/interpreter_functional/test_suites/run_pipeline/index.ts
index c33a87a93b9038..18d20c97be81e9 100644
--- a/test/interpreter_functional/test_suites/run_pipeline/index.ts
+++ b/test/interpreter_functional/test_suites/run_pipeline/index.ts
@@ -36,5 +36,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid
loadTestFile(require.resolve('./tag_cloud'));
loadTestFile(require.resolve('./metric'));
loadTestFile(require.resolve('./esaggs'));
+ loadTestFile(require.resolve('./esaggs_timeshift'));
});
}
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 94065f316340cb..45abbf120042d0 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
@@ -18,6 +18,8 @@ import {
EuiButtonEmpty,
EuiLink,
EuiPageContentBody,
+ EuiButton,
+ EuiSpacer,
} from '@elastic/eui';
import { CoreStart, ApplicationStart } from 'kibana/public';
import {
@@ -80,7 +82,11 @@ export interface WorkspacePanelProps {
}
interface WorkspaceState {
- expressionBuildError?: Array<{ shortMessage: string; longMessage: string }>;
+ expressionBuildError?: Array<{
+ shortMessage: string;
+ longMessage: string;
+ fixAction?: { label: string; newState: (framePublicAPI: FramePublicAPI) => Promise };
+ }>;
expandError: boolean;
}
@@ -335,6 +341,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
localState={{ ...localState, configurationValidationError, missingRefsErrors }}
ExpressionRendererComponent={ExpressionRendererComponent}
application={core.application}
+ activeDatasourceId={activeDatasourceId}
/>
);
};
@@ -398,6 +405,7 @@ export const VisualizationWrapper = ({
ExpressionRendererComponent,
dispatch,
application,
+ activeDatasourceId,
}: {
expression: string | null | undefined;
framePublicAPI: FramePublicAPI;
@@ -406,11 +414,16 @@ export const VisualizationWrapper = ({
dispatch: (action: Action) => void;
setLocalState: (dispatch: (prevState: WorkspaceState) => WorkspaceState) => void;
localState: WorkspaceState & {
- configurationValidationError?: Array<{ shortMessage: string; longMessage: string }>;
+ configurationValidationError?: Array<{
+ shortMessage: string;
+ longMessage: string;
+ fixAction?: { label: string; newState: (framePublicAPI: FramePublicAPI) => Promise };
+ }>;
missingRefsErrors?: Array<{ shortMessage: string; longMessage: string }>;
};
ExpressionRendererComponent: ReactExpressionRendererType;
application: ApplicationStart;
+ activeDatasourceId: string | null;
}) => {
const context: ExecutionContextSearch = useMemo(
() => ({
@@ -440,6 +453,41 @@ export const VisualizationWrapper = ({
[dispatchLens]
);
+ function renderFixAction(
+ validationError:
+ | {
+ shortMessage: string;
+ longMessage: string;
+ fixAction?:
+ | { label: string; newState: (framePublicAPI: FramePublicAPI) => Promise }
+ | undefined;
+ }
+ | undefined
+ ) {
+ return (
+ validationError &&
+ validationError.fixAction &&
+ activeDatasourceId && (
+ <>
+ {
+ const newState = await validationError.fixAction?.newState(framePublicAPI);
+ dispatch({
+ type: 'UPDATE_DATASOURCE_STATE',
+ datasourceId: activeDatasourceId,
+ updater: newState,
+ });
+ }}
+ >
+ {validationError.fixAction.label}
+
+
+ >
+ )
+ );
+ }
+
if (localState.configurationValidationError?.length) {
let showExtraErrors = null;
let showExtraErrorsAction = null;
@@ -448,14 +496,17 @@ export const VisualizationWrapper = ({
if (localState.expandError) {
showExtraErrors = localState.configurationValidationError
.slice(1)
- .map(({ longMessage }) => (
-
- {longMessage}
-
+ .map((validationError) => (
+ <>
+
+ {validationError.longMessage}
+
+ {renderFixAction(validationError)}
+ >
));
} else {
showExtraErrorsAction = (
@@ -487,6 +538,7 @@ export const VisualizationWrapper = ({
{localState.configurationValidationError[0].longMessage}
+ {renderFixAction(localState.configurationValidationError?.[0])}
{showExtraErrors}
>
@@ -546,6 +598,7 @@ export const VisualizationWrapper = ({
}
if (localState.expressionBuildError?.length) {
+ const firstError = localState.expressionBuildError[0];
return (
@@ -559,7 +612,7 @@ export const VisualizationWrapper = ({
/>
- {localState.expressionBuildError[0].longMessage}
+ {firstError.longMessage}
>
}
iconColor="danger"
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx
index 85f7601d8fb292..ec12e9e4002039 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx
@@ -60,9 +60,20 @@ export function WorkspacePanelWrapper({
},
[dispatch, activeVisualization]
);
- const warningMessages =
- activeVisualization?.getWarningMessages &&
- activeVisualization.getWarningMessages(visualizationState, framePublicAPI);
+ const warningMessages: React.ReactNode[] = [];
+ if (activeVisualization?.getWarningMessages) {
+ warningMessages.push(
+ ...(activeVisualization.getWarningMessages(visualizationState, framePublicAPI) || [])
+ );
+ }
+ Object.entries(datasourceStates).forEach(([datasourceId, datasourceState]) => {
+ const datasource = datasourceMap[datasourceId];
+ if (!datasourceState.isLoading && datasource.getWarningMessages) {
+ warningMessages.push(
+ ...(datasource.getWarningMessages(datasourceState.state, framePublicAPI) || [])
+ );
+ }
+ });
return (
<>
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx
index ea5eb14d9c20eb..c8676faad0eea7 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx
@@ -20,9 +20,7 @@ export function AdvancedOptions(props: {
}) {
const [popoverOpen, setPopoverOpen] = useState(false);
const popoverOptions = props.options.filter((option) => option.showInPopover);
- const inlineOptions = props.options
- .filter((option) => option.inlineElement)
- .map((option) => React.cloneElement(option.inlineElement!, { key: option.dataTestSubj }));
+ const inlineOptions = props.options.filter((option) => option.inlineElement);
return (
<>
@@ -74,7 +72,12 @@ export function AdvancedOptions(props: {
{inlineOptions.length > 0 && (
<>
- {inlineOptions}
+ {inlineOptions.map((option, index) => (
+ <>
+ {React.cloneElement(option.inlineElement!, { key: option.dataTestSubj })}
+ {index !== inlineOptions.length - 1 &&
}
+ >
+ ))}
>
)}
>
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 7732b53db62fb9..2ae7b9403a46db 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
@@ -43,6 +43,7 @@ import { ReferenceEditor } from './reference_editor';
import { setTimeScaling, TimeScaling } from './time_scaling';
import { defaultFilter, Filtering, setFilter } from './filtering';
import { AdvancedOptions } from './advanced_options';
+import { setTimeShift, TimeShift } from './time_shift';
import { useDebouncedValue } from '../../shared_components';
const operationPanels = getOperationDisplay();
@@ -142,6 +143,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
}, [fieldByOperation, operationWithoutField]);
const [filterByOpenInitially, setFilterByOpenInitally] = useState(false);
+ const [timeShiftedFocused, setTimeShiftFocused] = useState(false);
// Operations are compatible if they match inputs. They are always compatible in
// the empty state. Field-based operations are not compatible with field-less operations.
@@ -506,6 +508,38 @@ export function DimensionEditor(props: DimensionEditorProps) {
/>
) : null,
},
+ {
+ title: i18n.translate('xpack.lens.indexPattern.timeShift.label', {
+ defaultMessage: 'Time shift',
+ }),
+ dataTestSubj: 'indexPattern-time-shift-enable',
+ onClick: () => {
+ setTimeShiftFocused(true);
+ setStateWrapper(setTimeShift(columnId, state.layers[layerId], ''));
+ },
+ showInPopover: Boolean(
+ operationDefinitionMap[selectedColumn.operationType].shiftable &&
+ selectedColumn.timeShift === undefined &&
+ (currentIndexPattern.timeFieldName ||
+ Object.values(state.layers[layerId].columns).some(
+ (col) => col.operationType === 'date_histogram'
+ ))
+ ),
+ inlineElement:
+ operationDefinitionMap[selectedColumn.operationType].shiftable &&
+ selectedColumn.timeShift !== undefined ? (
+
+ ) : null,
+ },
]}
/>
)}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx
index 25cf20e304daf0..03db6141b917f9 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx
@@ -33,6 +33,9 @@ import { OperationMetadata } from '../../types';
import { DateHistogramIndexPatternColumn } from '../operations/definitions/date_histogram';
import { getFieldByNameFactory } from '../pure_helpers';
import { Filtering } from './filtering';
+import { TimeShift } from './time_shift';
+import { DimensionEditor } from './dimension_editor';
+import { AdvancedOptions } from './advanced_options';
jest.mock('../loader');
jest.mock('../query_input', () => ({
@@ -1319,6 +1322,196 @@ describe('IndexPatternDimensionEditorPanel', () => {
});
});
+ describe('time shift', () => {
+ function getProps(colOverrides: Partial
) {
+ return {
+ ...defaultProps,
+ state: getStateWithColumns({
+ datecolumn: {
+ dataType: 'date',
+ isBucketed: true,
+ label: '',
+ customLabel: true,
+ operationType: 'date_histogram',
+ sourceField: 'ts',
+ params: {
+ interval: '1d',
+ },
+ },
+ col2: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Count of records',
+ operationType: 'count',
+ sourceField: 'Records',
+ ...colOverrides,
+ } as IndexPatternColumn,
+ }),
+ columnId: 'col2',
+ };
+ }
+
+ it('should not show custom options if time shift is not available', () => {
+ const props = {
+ ...defaultProps,
+ state: getStateWithColumns({
+ col2: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Count of records',
+ operationType: 'count',
+ sourceField: 'Records',
+ } as IndexPatternColumn,
+ }),
+ columnId: 'col2',
+ };
+ wrapper = shallow(
+
+ );
+ expect(
+ wrapper
+ .find(DimensionEditor)
+ .dive()
+ .find(AdvancedOptions)
+ .dive()
+ .find('[data-test-subj="indexPattern-time-shift-enable"]')
+ ).toHaveLength(0);
+ });
+
+ it('should show custom options if time shift is available', () => {
+ wrapper = shallow();
+ expect(
+ wrapper
+ .find(DimensionEditor)
+ .dive()
+ .find(AdvancedOptions)
+ .dive()
+ .find('[data-test-subj="indexPattern-time-shift-enable"]')
+ ).toHaveLength(1);
+ });
+
+ it('should show current time shift if set', () => {
+ wrapper = mount();
+ expect(wrapper.find(TimeShift).find(EuiComboBox).prop('selectedOptions')[0].value).toEqual(
+ '1d'
+ );
+ });
+
+ it('should allow to set time shift initially', () => {
+ const props = getProps({});
+ wrapper = shallow();
+ wrapper
+ .find(DimensionEditor)
+ .dive()
+ .find(AdvancedOptions)
+ .dive()
+ .find('[data-test-subj="indexPattern-time-shift-enable"]')
+ .prop('onClick')!({} as MouseEvent);
+ expect((props.setState as jest.Mock).mock.calls[0][0](props.state)).toEqual({
+ ...props.state,
+ layers: {
+ first: {
+ ...props.state.layers.first,
+ columns: {
+ ...props.state.layers.first.columns,
+ col2: expect.objectContaining({
+ timeShift: '',
+ }),
+ },
+ },
+ },
+ });
+ });
+
+ it('should carry over time shift to other operation if possible', () => {
+ const props = getProps({
+ timeShift: '1d',
+ sourceField: 'bytes',
+ operationType: 'sum',
+ label: 'Sum of bytes per hour',
+ });
+ wrapper = mount();
+ wrapper
+ .find('button[data-test-subj="lns-indexPatternDimension-count incompatible"]')
+ .simulate('click');
+ expect((props.setState as jest.Mock).mock.calls[0][0](props.state)).toEqual({
+ ...props.state,
+ layers: {
+ first: {
+ ...props.state.layers.first,
+ columns: {
+ ...props.state.layers.first.columns,
+ col2: expect.objectContaining({
+ timeShift: '1d',
+ }),
+ },
+ },
+ },
+ });
+ });
+
+ it('should allow to change time shift', () => {
+ const props = getProps({
+ timeShift: '1d',
+ });
+ wrapper = mount();
+ wrapper.find(TimeShift).find(EuiComboBox).prop('onCreateOption')!('1h', []);
+ expect((props.setState as jest.Mock).mock.calls[0][0](props.state)).toEqual({
+ ...props.state,
+ layers: {
+ first: {
+ ...props.state.layers.first,
+ columns: {
+ ...props.state.layers.first.columns,
+ col2: expect.objectContaining({
+ timeShift: '1h',
+ }),
+ },
+ },
+ },
+ });
+ });
+
+ it('should allow to time shift', () => {
+ const props = getProps({
+ timeShift: '1h',
+ });
+ wrapper = mount();
+ wrapper
+ .find('[data-test-subj="indexPattern-time-shift-remove"]')
+ .find(EuiButtonIcon)
+ .prop('onClick')!(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ {} as any
+ );
+ expect((props.setState as jest.Mock).mock.calls[0][0](props.state)).toEqual({
+ ...props.state,
+ layers: {
+ first: {
+ ...props.state.layers.first,
+ columns: {
+ ...props.state.layers.first.columns,
+ col2: expect.objectContaining({
+ timeShift: undefined,
+ }),
+ },
+ },
+ },
+ });
+ });
+ });
+
describe('filtering', () => {
function getProps(colOverrides: Partial) {
return {
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx
index bf5b64bf3d6152..61e5da5931e88e 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_scaling.tsx
@@ -27,7 +27,13 @@ export function setTimeScaling(
const currentColumn = layer.columns[columnId];
const label = currentColumn.customLabel
? currentColumn.label
- : adjustTimeScaleLabelSuffix(currentColumn.label, currentColumn.timeScale, timeScale);
+ : adjustTimeScaleLabelSuffix(
+ currentColumn.label,
+ currentColumn.timeScale,
+ timeScale,
+ currentColumn.timeShift,
+ currentColumn.timeShift
+ );
return {
...layer,
columns: {
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx
new file mode 100644
index 00000000000000..0ac02c15b34a50
--- /dev/null
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/time_shift.tsx
@@ -0,0 +1,394 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { EuiButtonIcon } from '@elastic/eui';
+import { EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
+import { EuiComboBox } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { uniq } from 'lodash';
+import { i18n } from '@kbn/i18n';
+import React, { useEffect, useRef, useState } from 'react';
+import { Query } from 'src/plugins/data/public';
+import { search } from '../../../../../../src/plugins/data/public';
+import { parseTimeShift } from '../../../../../../src/plugins/data/common';
+import {
+ adjustTimeScaleLabelSuffix,
+ IndexPatternColumn,
+ operationDefinitionMap,
+} from '../operations';
+import { IndexPattern, IndexPatternLayer, IndexPatternPrivateState } from '../types';
+import { IndexPatternDimensionEditorProps } from './dimension_panel';
+import { FramePublicAPI } from '../../types';
+
+// to do: get the language from uiSettings
+export const defaultFilter: Query = {
+ query: '',
+ language: 'kuery',
+};
+
+export function setTimeShift(
+ columnId: string,
+ layer: IndexPatternLayer,
+ timeShift: string | undefined
+) {
+ const trimmedTimeShift = timeShift?.trim();
+ const currentColumn = layer.columns[columnId];
+ const label = currentColumn.customLabel
+ ? currentColumn.label
+ : adjustTimeScaleLabelSuffix(
+ currentColumn.label,
+ currentColumn.timeScale,
+ currentColumn.timeScale,
+ currentColumn.timeShift,
+ trimmedTimeShift
+ );
+ return {
+ ...layer,
+ columns: {
+ ...layer.columns,
+ [columnId]: {
+ ...layer.columns[columnId],
+ label,
+ timeShift: trimmedTimeShift,
+ },
+ },
+ };
+}
+
+const timeShiftOptions = [
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.hour', {
+ defaultMessage: '1 hour (1h)',
+ }),
+ value: '1h',
+ },
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.3hours', {
+ defaultMessage: '3 hours (3h)',
+ }),
+ value: '3h',
+ },
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.6hours', {
+ defaultMessage: '6 hours (6h)',
+ }),
+ value: '6h',
+ },
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.12hours', {
+ defaultMessage: '12 hours (12h)',
+ }),
+ value: '12h',
+ },
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.day', {
+ defaultMessage: '1 day (1d)',
+ }),
+ value: '1d',
+ },
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.week', {
+ defaultMessage: '1 week (1w)',
+ }),
+ value: '1w',
+ },
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.month', {
+ defaultMessage: '1 month (1M)',
+ }),
+ value: '1M',
+ },
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.3months', {
+ defaultMessage: '3 months (3M)',
+ }),
+ value: '3M',
+ },
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.6months', {
+ defaultMessage: '6 months (6M)',
+ }),
+ value: '6M',
+ },
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.year', {
+ defaultMessage: '1 year (1y)',
+ }),
+ value: '1y',
+ },
+ {
+ label: i18n.translate('xpack.lens.indexPattern.timeShift.previous', {
+ defaultMessage: 'Previous',
+ }),
+ value: 'previous',
+ },
+];
+
+export function TimeShift({
+ selectedColumn,
+ columnId,
+ layer,
+ updateLayer,
+ indexPattern,
+ isFocused,
+ activeData,
+ layerId,
+}: {
+ selectedColumn: IndexPatternColumn;
+ indexPattern: IndexPattern;
+ columnId: string;
+ layer: IndexPatternLayer;
+ updateLayer: (newLayer: IndexPatternLayer) => void;
+ isFocused: boolean;
+ activeData: IndexPatternDimensionEditorProps['activeData'];
+ layerId: string;
+}) {
+ const focusSetRef = useRef(false);
+ const [localValue, setLocalValue] = useState(selectedColumn.timeShift);
+ useEffect(() => {
+ setLocalValue(selectedColumn.timeShift);
+ }, [selectedColumn.timeShift]);
+ const selectedOperation = operationDefinitionMap[selectedColumn.operationType];
+ if (!selectedOperation.shiftable || selectedColumn.timeShift === undefined) {
+ return null;
+ }
+
+ let dateHistogramInterval: null | moment.Duration = null;
+ const dateHistogramColumn = layer.columnOrder.find(
+ (colId) => layer.columns[colId].operationType === 'date_histogram'
+ );
+ if (!dateHistogramColumn && !indexPattern.timeFieldName) {
+ return null;
+ }
+ if (dateHistogramColumn && activeData && activeData[layerId] && activeData[layerId]) {
+ const column = activeData[layerId].columns.find((col) => col.id === dateHistogramColumn);
+ if (column) {
+ dateHistogramInterval = search.aggs.parseInterval(
+ search.aggs.getDateHistogramMetaDataByDatatableColumn(column)?.interval || ''
+ );
+ }
+ }
+
+ function isValueTooSmall(parsedValue: ReturnType) {
+ return (
+ dateHistogramInterval &&
+ parsedValue &&
+ typeof parsedValue === 'object' &&
+ parsedValue.asMilliseconds() < dateHistogramInterval.asMilliseconds()
+ );
+ }
+
+ function isValueNotMultiple(parsedValue: ReturnType) {
+ return (
+ dateHistogramInterval &&
+ parsedValue &&
+ typeof parsedValue === 'object' &&
+ !Number.isInteger(parsedValue.asMilliseconds() / dateHistogramInterval.asMilliseconds())
+ );
+ }
+
+ const parsedLocalValue = localValue && parseTimeShift(localValue);
+ const isLocalValueInvalid = Boolean(parsedLocalValue === 'invalid');
+ const localValueTooSmall = parsedLocalValue && isValueTooSmall(parsedLocalValue);
+ const localValueNotMultiple = parsedLocalValue && isValueNotMultiple(parsedLocalValue);
+
+ function getSelectedOption() {
+ if (!localValue) return [];
+ const goodPick = timeShiftOptions.filter(({ value }) => value === localValue);
+ if (goodPick.length > 0) return goodPick;
+ return [
+ {
+ value: localValue,
+ label: localValue,
+ },
+ ];
+ }
+
+ return (
+ {
+ if (r && isFocused) {
+ const timeShiftInput = r.querySelector('[data-test-subj="comboBoxSearchInput"]');
+ if (!focusSetRef.current && timeShiftInput instanceof HTMLInputElement) {
+ focusSetRef.current = true;
+ timeShiftInput.focus();
+ }
+ }
+ }}
+ >
+
+
+
+ {
+ const parsedValue = parseTimeShift(value);
+ return (
+ parsedValue && !isValueTooSmall(parsedValue) && !isValueNotMultiple(parsedValue)
+ );
+ })}
+ selectedOptions={getSelectedOption()}
+ singleSelection={{ asPlainText: true }}
+ isInvalid={isLocalValueInvalid}
+ onCreateOption={(val) => {
+ const parsedVal = parseTimeShift(val);
+ if (parsedVal !== 'invalid') {
+ updateLayer(setTimeShift(columnId, layer, val));
+ } else {
+ setLocalValue(val);
+ }
+ }}
+ onChange={(choices) => {
+ if (choices.length === 0) {
+ updateLayer(setTimeShift(columnId, layer, ''));
+ setLocalValue('');
+ return;
+ }
+
+ const choice = choices[0].value as string;
+ const parsedVal = parseTimeShift(choice);
+ if (parsedVal !== 'invalid') {
+ updateLayer(setTimeShift(columnId, layer, choice));
+ } else {
+ setLocalValue(choice);
+ }
+ }}
+ />
+
+
+ {
+ updateLayer(setTimeShift(columnId, layer, undefined));
+ }}
+ iconType="cross"
+ />
+
+
+
+
+ );
+}
+
+export function getTimeShiftWarningMessages(
+ state: IndexPatternPrivateState,
+ { activeData }: FramePublicAPI
+) {
+ if (!state) return;
+ const warningMessages: React.ReactNode[] = [];
+ Object.entries(state.layers).forEach(([layerId, layer]) => {
+ let dateHistogramInterval: null | string = null;
+ const dateHistogramColumn = layer.columnOrder.find(
+ (colId) => layer.columns[colId].operationType === 'date_histogram'
+ );
+ if (!dateHistogramColumn) {
+ return;
+ }
+ if (dateHistogramColumn && activeData && activeData[layerId]) {
+ const column = activeData[layerId].columns.find((col) => col.id === dateHistogramColumn);
+ if (column) {
+ dateHistogramInterval =
+ search.aggs.getDateHistogramMetaDataByDatatableColumn(column)?.interval || null;
+ }
+ }
+ if (dateHistogramInterval === null) {
+ return;
+ }
+ const shiftInterval = search.aggs.parseInterval(dateHistogramInterval)!.asMilliseconds();
+ let timeShifts: number[] = [];
+ const timeShiftMap: Record = {};
+ Object.entries(layer.columns).forEach(([columnId, column]) => {
+ if (column.isBucketed) return;
+ let duration: number = 0;
+ if (column.timeShift) {
+ const parsedTimeShift = parseTimeShift(column.timeShift);
+ if (parsedTimeShift === 'previous' || parsedTimeShift === 'invalid') {
+ return;
+ }
+ duration = parsedTimeShift.asMilliseconds();
+ }
+ timeShifts.push(duration);
+ if (!timeShiftMap[duration]) {
+ timeShiftMap[duration] = [];
+ }
+ timeShiftMap[duration].push(columnId);
+ });
+ timeShifts = uniq(timeShifts);
+
+ if (timeShifts.length < 2) {
+ return;
+ }
+
+ timeShifts.forEach((timeShift) => {
+ if (timeShift === 0) return;
+ if (timeShift < shiftInterval) {
+ timeShiftMap[timeShift].forEach((columnId) => {
+ warningMessages.push(
+ {layer.columns[columnId].label},
+ interval: {dateHistogramInterval},
+ columnTimeShift: {layer.columns[columnId].timeShift},
+ }}
+ />
+ );
+ });
+ } else if (!Number.isInteger(timeShift / shiftInterval)) {
+ timeShiftMap[timeShift].forEach((columnId) => {
+ warningMessages.push(
+ {layer.columns[columnId].label},
+ interval: dateHistogramInterval,
+ columnTimeShift: layer.columns[columnId].timeShift!,
+ }}
+ />
+ );
+ });
+ }
+ });
+ });
+ return warningMessages;
+}
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 81dff1da578094..64b0bdd7ca2a6a 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
@@ -7,7 +7,7 @@
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { getIndexPatternDatasource, IndexPatternColumn } from './indexpattern';
-import { DatasourcePublicAPI, Operation, Datasource } from '../types';
+import { DatasourcePublicAPI, Operation, Datasource, FramePublicAPI } from '../types';
import { coreMock } from 'src/core/public/mocks';
import { IndexPatternPersistedState, IndexPatternPrivateState } from './types';
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
@@ -18,6 +18,7 @@ import { operationDefinitionMap, getErrorMessages } from './operations';
import { createMockedFullReference } from './operations/mocks';
import { indexPatternFieldEditorPluginMock } from 'src/plugins/index_pattern_field_editor/public/mocks';
import { uiActionsPluginMock } from '../../../../../src/plugins/ui_actions/public/mocks';
+import React from 'react';
jest.mock('./loader');
jest.mock('../id_generator');
@@ -500,6 +501,43 @@ describe('IndexPattern Data Source', () => {
expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']);
});
+ it('should pass time shift parameter to metric agg functions', async () => {
+ const queryBaseState: IndexPatternBaseState = {
+ currentIndexPatternId: '1',
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col2', 'col1'],
+ columns: {
+ col1: {
+ label: 'Count of records',
+ dataType: 'number',
+ isBucketed: false,
+ sourceField: 'Records',
+ operationType: 'count',
+ timeShift: '1d',
+ },
+ col2: {
+ label: 'Date',
+ dataType: 'date',
+ isBucketed: true,
+ operationType: 'date_histogram',
+ sourceField: 'timestamp',
+ params: {
+ interval: 'auto',
+ },
+ },
+ },
+ },
+ },
+ };
+
+ const state = enrichBaseState(queryBaseState);
+
+ const ast = indexPatternDatasource.toExpression(state, 'first') as Ast;
+ expect((ast.chain[0].arguments.aggs[1] as Ast).chain[0].arguments.timeShift).toEqual(['1d']);
+ });
+
it('should wrap filtered metrics in filtered metric aggregation', async () => {
const queryBaseState: IndexPatternBaseState = {
currentIndexPatternId: '1',
@@ -1267,6 +1305,135 @@ describe('IndexPattern Data Source', () => {
});
});
+ describe('#getWarningMessages', () => {
+ it('should return mismatched time shifts', () => {
+ const state: IndexPatternPrivateState = {
+ indexPatternRefs: [],
+ existingFields: {},
+ isFirstExistenceFetch: false,
+ indexPatterns: expectedIndexPatterns,
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5', 'col6'],
+ columns: {
+ col1: {
+ operationType: 'date_histogram',
+ params: {
+ interval: '12h',
+ },
+ label: '',
+ dataType: 'date',
+ isBucketed: true,
+ sourceField: 'timestamp',
+ },
+ col2: {
+ operationType: 'count',
+ label: '',
+ dataType: 'number',
+ isBucketed: false,
+ sourceField: 'records',
+ },
+ col3: {
+ operationType: 'count',
+ timeShift: '1h',
+ label: '',
+ dataType: 'number',
+ isBucketed: false,
+ sourceField: 'records',
+ },
+ col4: {
+ operationType: 'count',
+ timeShift: '13h',
+ label: '',
+ dataType: 'number',
+ isBucketed: false,
+ sourceField: 'records',
+ },
+ col5: {
+ operationType: 'count',
+ timeShift: '1w',
+ label: '',
+ dataType: 'number',
+ isBucketed: false,
+ sourceField: 'records',
+ },
+ col6: {
+ operationType: 'count',
+ timeShift: 'previous',
+ label: '',
+ dataType: 'number',
+ isBucketed: false,
+ sourceField: 'records',
+ },
+ },
+ },
+ },
+ currentIndexPatternId: '1',
+ };
+ const warnings = indexPatternDatasource.getWarningMessages!(state, ({
+ activeData: {
+ first: {
+ type: 'datatable',
+ rows: [],
+ columns: [
+ {
+ id: 'col1',
+ name: 'col1',
+ meta: {
+ type: 'date',
+ source: 'esaggs',
+ sourceParams: {
+ type: 'date_histogram',
+ params: {
+ used_interval: '12h',
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ } as unknown) as FramePublicAPI);
+ expect(warnings!.length).toBe(2);
+ expect((warnings![0] as React.ReactElement).props.id).toEqual(
+ 'xpack.lens.indexPattern.timeShiftSmallWarning'
+ );
+ expect((warnings![1] as React.ReactElement).props.id).toEqual(
+ 'xpack.lens.indexPattern.timeShiftMultipleWarning'
+ );
+ });
+
+ it('should prepend each error with its layer number on multi-layer chart', () => {
+ (getErrorMessages as jest.Mock).mockClear();
+ (getErrorMessages as jest.Mock).mockReturnValueOnce(['error 1', 'error 2']);
+ const state: IndexPatternPrivateState = {
+ indexPatternRefs: [],
+ existingFields: {},
+ isFirstExistenceFetch: false,
+ indexPatterns: expectedIndexPatterns,
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: [],
+ columns: {},
+ },
+ second: {
+ indexPatternId: '1',
+ columnOrder: [],
+ columns: {},
+ },
+ },
+ currentIndexPatternId: '1',
+ };
+ expect(indexPatternDatasource.getErrorMessages(state)).toEqual([
+ { longMessage: 'Layer 1 error: error 1', shortMessage: '' },
+ { longMessage: 'Layer 1 error: error 2', shortMessage: '' },
+ ]);
+ expect(getErrorMessages).toHaveBeenCalledTimes(2);
+ });
+ });
+
describe('#updateStateOnCloseDimension', () => {
it('should not update when there are no incomplete columns', () => {
expect(
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
index 8b60cf134fe6fa..7cb49de15c0665 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
@@ -55,6 +55,7 @@ import { deleteColumn, isReferenced } from './operations';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import { GeoFieldWorkspacePanel } from '../editor_frame_service/editor_frame/workspace_panel/geo_field_workspace_panel';
import { DraggingIdentifier } from '../drag_drop';
+import { getTimeShiftWarningMessages } from './dimension_panel/time_shift';
export { OperationType, IndexPatternColumn, deleteColumn } from './operations';
@@ -407,13 +408,20 @@ export function getIndexPatternDatasource({
}
// Forward the indexpattern as well, as it is required by some operationType checks
- const layerErrors = Object.values(state.layers).map((layer) =>
- (getErrorMessages(layer, state.indexPatterns[layer.indexPatternId]) ?? []).map(
- (message) => ({
- shortMessage: '', // Not displayed currently
- longMessage: message,
- })
- )
+ const layerErrors = Object.entries(state.layers).map(([layerId, layer]) =>
+ (
+ getErrorMessages(
+ layer,
+ state.indexPatterns[layer.indexPatternId],
+ state,
+ layerId,
+ core
+ ) ?? []
+ ).map((message) => ({
+ shortMessage: '', // Not displayed currently
+ longMessage: typeof message === 'string' ? message : message.message,
+ fixAction: typeof message === 'object' ? message.fixAction : undefined,
+ }))
);
// Single layer case, no need to explain more
@@ -449,6 +457,7 @@ export function getIndexPatternDatasource({
});
return messages.length ? messages : undefined;
},
+ getWarningMessages: getTimeShiftWarningMessages,
checkIntegrity: (state) => {
const ids = Object.values(state.layers || {}).map(({ indexPatternId }) => indexPatternId);
return ids.filter((id) => !state.indexPatterns[id]);
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
index fc9504f003198c..823ec3eb58a924 100644
--- 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
@@ -70,7 +70,8 @@ export const counterRateOperation: OperationDefinition<
ref && 'sourceField' in ref
? indexPattern.getFieldByName(ref.sourceField)?.displayName
: undefined,
- column.timeScale
+ column.timeScale,
+ column.timeShift
);
},
toExpression: (layer, columnId) => {
@@ -84,7 +85,8 @@ export const counterRateOperation: OperationDefinition<
metric && 'sourceField' in metric
? indexPattern.getFieldByName(metric.sourceField)?.displayName
: undefined,
- timeScale
+ timeScale,
+ previousColumn?.timeShift
),
dataType: 'number',
operationType: 'counter_rate',
@@ -92,6 +94,7 @@ export const counterRateOperation: OperationDefinition<
scale: 'ratio',
references: referenceIds,
timeScale,
+ timeShift: previousColumn?.timeShift,
filter: getFilter(previousColumn, columnParams),
params: getFormatFromPreviousColumn(previousColumn),
};
@@ -118,4 +121,5 @@ export const counterRateOperation: OperationDefinition<
},
timeScalingMode: 'mandatory',
filterable: true,
+ shiftable: true,
};
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
index 2adb9a1376f606..c4f01e27be886f 100644
--- 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
@@ -13,11 +13,12 @@ import {
getErrorsForDateReference,
dateBasedOperationToExpression,
hasDateField,
+ buildLabelFunction,
} from './utils';
import { OperationDefinition } from '..';
import { getFormatFromPreviousColumn, getFilter } from '../helpers';
-const ofName = (name?: string) => {
+const ofName = buildLabelFunction((name?: string) => {
return i18n.translate('xpack.lens.indexPattern.cumulativeSumOf', {
defaultMessage: 'Cumulative sum of {name}',
values: {
@@ -28,7 +29,7 @@ const ofName = (name?: string) => {
}),
},
});
-};
+});
export type CumulativeSumIndexPatternColumn = FormattedIndexPatternColumn &
ReferenceBasedIndexPatternColumn & {
@@ -67,7 +68,9 @@ export const cumulativeSumOperation: OperationDefinition<
return ofName(
ref && 'sourceField' in ref
? indexPattern.getFieldByName(ref.sourceField)?.displayName
- : undefined
+ : undefined,
+ undefined,
+ column.timeShift
);
},
toExpression: (layer, columnId) => {
@@ -79,12 +82,15 @@ export const cumulativeSumOperation: OperationDefinition<
label: ofName(
ref && 'sourceField' in ref
? indexPattern.getFieldByName(ref.sourceField)?.displayName
- : undefined
+ : undefined,
+ undefined,
+ previousColumn?.timeShift
),
dataType: 'number',
operationType: 'cumulative_sum',
isBucketed: false,
scale: 'ratio',
+ timeShift: previousColumn?.timeShift,
filter: getFilter(previousColumn, columnParams),
references: referenceIds,
params: getFormatFromPreviousColumn(previousColumn),
@@ -111,4 +117,5 @@ export const cumulativeSumOperation: OperationDefinition<
)?.join(', ');
},
filterable: true,
+ shiftable: true,
};
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx
index 06555a9b41c2ff..7c48b5742b8dbb 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx
@@ -66,7 +66,7 @@ export const derivativeOperation: OperationDefinition<
}
},
getDefaultLabel: (column, indexPattern, columns) => {
- return ofName(columns[column.references[0]]?.label, column.timeScale);
+ return ofName(columns[column.references[0]]?.label, column.timeScale, column.timeShift);
},
toExpression: (layer, columnId) => {
return dateBasedOperationToExpression(layer, columnId, 'derivative');
@@ -74,7 +74,7 @@ export const derivativeOperation: OperationDefinition<
buildColumn: ({ referenceIds, previousColumn, layer }, columnParams) => {
const ref = layer.columns[referenceIds[0]];
return {
- label: ofName(ref?.label, previousColumn?.timeScale),
+ label: ofName(ref?.label, previousColumn?.timeScale, previousColumn?.timeShift),
dataType: 'number',
operationType: OPERATION_NAME,
isBucketed: false,
@@ -82,6 +82,7 @@ export const derivativeOperation: OperationDefinition<
references: referenceIds,
timeScale: previousColumn?.timeScale,
filter: getFilter(previousColumn, columnParams),
+ timeShift: previousColumn?.timeShift,
params: getFormatFromPreviousColumn(previousColumn),
};
},
@@ -108,4 +109,5 @@ export const derivativeOperation: OperationDefinition<
},
timeScalingMode: 'optional',
filterable: true,
+ shiftable: true,
};
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
index 0e74ef6b85c804..a3d0241d4887e9 100644
--- 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
@@ -76,7 +76,7 @@ export const movingAverageOperation: OperationDefinition<
}
},
getDefaultLabel: (column, indexPattern, columns) => {
- return ofName(columns[column.references[0]]?.label, column.timeScale);
+ return ofName(columns[column.references[0]]?.label, column.timeScale, column.timeShift);
},
toExpression: (layer, columnId) => {
return dateBasedOperationToExpression(layer, columnId, 'moving_average', {
@@ -90,7 +90,7 @@ export const movingAverageOperation: OperationDefinition<
const metric = layer.columns[referenceIds[0]];
const { window = WINDOW_DEFAULT_VALUE } = columnParams;
return {
- label: ofName(metric?.label, previousColumn?.timeScale),
+ label: ofName(metric?.label, previousColumn?.timeScale, previousColumn?.timeShift),
dataType: 'number',
operationType: 'moving_average',
isBucketed: false,
@@ -98,6 +98,7 @@ export const movingAverageOperation: OperationDefinition<
references: referenceIds,
timeScale: previousColumn?.timeScale,
filter: getFilter(previousColumn, columnParams),
+ timeShift: previousColumn?.timeShift,
params: {
window,
...getFormatFromPreviousColumn(previousColumn),
@@ -129,6 +130,7 @@ export const movingAverageOperation: OperationDefinition<
},
timeScalingMode: 'optional',
filterable: true,
+ shiftable: true,
};
function MovingAverageParamEditor({
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
index 59dbf74c11480c..1f4f097c6a7fb5 100644
--- 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
@@ -16,10 +16,11 @@ import { operationDefinitionMap } from '..';
export const buildLabelFunction = (ofName: (name?: string) => string) => (
name?: string,
- timeScale?: TimeScaleUnit
+ timeScale?: TimeScaleUnit,
+ timeShift?: string
) => {
const rawLabel = ofName(name);
- return adjustTimeScaleLabelSuffix(rawLabel, undefined, timeScale);
+ return adjustTimeScaleLabelSuffix(rawLabel, undefined, timeScale, undefined, timeShift);
};
/**
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx
index e77357a6f441a7..1911af0a6f679b 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx
@@ -17,6 +17,7 @@ import {
getSafeName,
getFilter,
} from './helpers';
+import { adjustTimeScaleLabelSuffix } from '../time_scale_utils';
const supportedTypes = new Set([
'string',
@@ -33,13 +34,19 @@ const SCALE = 'ratio';
const OPERATION_TYPE = 'unique_count';
const IS_BUCKETED = false;
-function ofName(name: string) {
- return i18n.translate('xpack.lens.indexPattern.cardinalityOf', {
- defaultMessage: 'Unique count of {name}',
- values: {
- name,
- },
- });
+function ofName(name: string, timeShift: string | undefined) {
+ return adjustTimeScaleLabelSuffix(
+ i18n.translate('xpack.lens.indexPattern.cardinalityOf', {
+ defaultMessage: 'Unique count of {name}',
+ values: {
+ name,
+ },
+ }),
+ undefined,
+ undefined,
+ undefined,
+ timeShift
+ );
}
export interface CardinalityIndexPatternColumn
@@ -76,21 +83,19 @@ export const cardinalityOperation: OperationDefinition ofName(getSafeName(column.sourceField, indexPattern)),
+ shiftable: true,
+ getDefaultLabel: (column, indexPattern) =>
+ ofName(getSafeName(column.sourceField, indexPattern), column.timeShift),
buildColumn({ field, previousColumn }, columnParams) {
return {
- label: ofName(field.displayName),
+ label: ofName(field.displayName, previousColumn?.timeShift),
dataType: 'number',
operationType: OPERATION_TYPE,
scale: SCALE,
sourceField: field.name,
isBucketed: IS_BUCKETED,
filter: getFilter(previousColumn, columnParams),
+ timeShift: previousColumn?.timeShift,
params: getFormatFromPreviousColumn(previousColumn),
};
},
@@ -100,12 +105,14 @@ export const cardinalityOperation: OperationDefinition {
return {
...oldColumn,
- label: ofName(field.displayName),
+ label: ofName(field.displayName, oldColumn.timeShift),
sourceField: field.name,
};
},
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 aa6b8675333c56..ae606a5851665e 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,6 +16,7 @@ export interface BaseIndexPatternColumn extends Operation {
customLabel?: boolean;
timeScale?: TimeScaleUnit;
filter?: Query;
+ timeShift?: string;
}
// Formatting can optionally be added to any column
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx
index fd474ea04a165b..7bf463a2095ad4 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx
@@ -38,7 +38,13 @@ export const countOperation: OperationDefinition {
return {
...oldColumn,
- label: adjustTimeScaleLabelSuffix(field.displayName, undefined, oldColumn.timeScale),
+ label: adjustTimeScaleLabelSuffix(
+ field.displayName,
+ undefined,
+ oldColumn.timeScale,
+ undefined,
+ oldColumn.timeShift
+ ),
sourceField: field.name,
};
},
@@ -51,10 +57,23 @@ export const countOperation: OperationDefinition adjustTimeScaleLabelSuffix(countLabel, undefined, column.timeScale),
+ getDefaultLabel: (column) =>
+ adjustTimeScaleLabelSuffix(
+ countLabel,
+ undefined,
+ column.timeScale,
+ undefined,
+ column.timeShift
+ ),
buildColumn({ field, previousColumn }, columnParams) {
return {
- label: adjustTimeScaleLabelSuffix(countLabel, undefined, previousColumn?.timeScale),
+ label: adjustTimeScaleLabelSuffix(
+ countLabel,
+ undefined,
+ previousColumn?.timeScale,
+ undefined,
+ previousColumn?.timeShift
+ ),
dataType: 'number',
operationType: 'count',
isBucketed: false,
@@ -62,6 +81,7 @@ export const countOperation: OperationDefinition {
@@ -89,4 +111,5 @@ export const countOperation: OperationDefinition col.timeShift && col.timeShift !== ''
+ );
+ if (!usesTimeShift) {
+ return undefined;
+ }
+ const dateHistograms = layer.columnOrder.filter(
+ (colId) => layer.columns[colId].operationType === 'date_histogram'
+ );
+ if (dateHistograms.length < 2) {
+ return undefined;
+ }
+ return i18n.translate('xpack.lens.indexPattern.multipleDateHistogramsError', {
+ defaultMessage:
+ '"{dimensionLabel}" is not the only date histogram. When using time shifts, make sure to only use one date histogram.',
+ values: {
+ dimensionLabel: layer.columns[columnId].label,
+ },
+ });
+}
+
export const dateHistogramOperation: OperationDefinition<
DateHistogramIndexPatternColumn,
'field'
@@ -60,7 +83,13 @@ export const dateHistogramOperation: OperationDefinition<
priority: 5, // Highest priority level used
operationParams: [{ name: 'interval', type: 'string', required: false }],
getErrorMessage: (layer, columnId, indexPattern) =>
- getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
+ [
+ ...(getInvalidFieldMessage(
+ layer.columns[columnId] as FieldBasedIndexPatternColumn,
+ indexPattern
+ ) || []),
+ getMultipleDateHistogramsErrorMessage(layer, columnId) || '',
+ ].filter(Boolean),
getHelpMessage: (props) => ,
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {
if (
@@ -150,7 +179,15 @@ export const dateHistogramOperation: OperationDefinition<
extended_bounds: JSON.stringify({}),
}).toAst();
},
- paramEditor: ({ layer, columnId, currentColumn, updateLayer, dateRange, data, indexPattern }) => {
+ paramEditor: function ParamEditor({
+ layer,
+ columnId,
+ currentColumn,
+ updateLayer,
+ dateRange,
+ data,
+ indexPattern,
+ }: ParamEditorProps) {
const field = currentColumn && indexPattern.getFieldByName(currentColumn.sourceField);
const intervalIsRestricted =
field!.aggregationRestrictions && field!.aggregationRestrictions.date_histogram;
@@ -225,10 +262,11 @@ export const dateHistogramOperation: OperationDefinition<
disabled={calendarOnlyIntervals.has(interval.unit)}
isInvalid={!isValid}
onChange={(e) => {
- setInterval({
+ const newInterval = {
...interval,
value: e.target.value,
- });
+ };
+ setInterval(newInterval);
}}
/>
@@ -238,10 +276,11 @@ export const dateHistogramOperation: OperationDefinition<
data-test-subj="lensDateHistogramUnit"
value={interval.unit}
onChange={(e) => {
- setInterval({
+ const newInterval = {
...interval,
unit: e.target.value,
- });
+ };
+ setInterval(newInterval);
}}
isInvalid={!isValid}
options={[
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 cbc83db7e5f376..164415c1a1f6f3 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
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
+import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreStart } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { termsOperation, TermsIndexPatternColumn } from './terms';
import { filtersOperation, FiltersIndexPatternColumn } from './filters';
@@ -42,13 +42,14 @@ import {
FormulaIndexPatternColumn,
} from './formula';
import { lastValueOperation, LastValueIndexPatternColumn } from './last_value';
-import { OperationMetadata } from '../../../types';
+import { FramePublicAPI, OperationMetadata } from '../../../types';
import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types';
import { IndexPattern, IndexPatternField, IndexPatternLayer } from '../../types';
import { DateRange } from '../../../../common';
import { ExpressionAstFunction } from '../../../../../../../src/plugins/expressions/public';
import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public';
import { RangeIndexPatternColumn, rangeOperation } from './ranges';
+import { IndexPatternDimensionEditorProps } from '../../dimension_panel';
/**
* A union type of all available column types. If a column is of an unknown type somewhere
@@ -160,6 +161,7 @@ export interface ParamEditorProps {
http: HttpSetup;
dateRange: DateRange;
data: DataPublicPluginStart;
+ activeData?: IndexPatternDimensionEditorProps['activeData'];
operationDefinitionMap: Record;
}
@@ -240,7 +242,22 @@ interface BaseOperationDefinitionProps {
columnId: string,
indexPattern: IndexPattern,
operationDefinitionMap?: Record
- ) => string[] | undefined;
+ ) =>
+ | Array<
+ | string
+ | {
+ message: string;
+ fixAction?: {
+ label: string;
+ newState: (
+ core: CoreStart,
+ frame: FramePublicAPI,
+ layerId: string
+ ) => Promise;
+ };
+ }
+ >
+ | undefined;
/*
* Flag whether this operation can be scaled by time unit if a date histogram is available.
@@ -255,6 +272,7 @@ interface BaseOperationDefinitionProps {
* autocomplete.
*/
filterable?: boolean;
+ shiftable?: boolean;
getHelpMessage?: (props: HelpProps) => React.ReactNode;
/*
@@ -366,12 +384,27 @@ interface FieldBasedOperationDefinition {
* - Requires a date histogram operation somewhere before it in order
* - Missing references
*/
- getErrorMessage: (
+ getErrorMessage?: (
layer: IndexPatternLayer,
columnId: string,
indexPattern: IndexPattern,
operationDefinitionMap?: Record
- ) => string[] | undefined;
+ ) =>
+ | Array<
+ | string
+ | {
+ message: string;
+ fixAction?: {
+ label: string;
+ newState: (
+ core: CoreStart,
+ frame: FramePublicAPI,
+ layerId: string
+ ) => Promise;
+ };
+ }
+ >
+ | undefined;
}
export interface RequiredReference {
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx
index 4632d262c441d4..bde80accfbc676 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.tsx
@@ -21,14 +21,21 @@ import {
getSafeName,
getFilter,
} from './helpers';
+import { adjustTimeScaleLabelSuffix } from '../time_scale_utils';
-function ofName(name: string) {
- return i18n.translate('xpack.lens.indexPattern.lastValueOf', {
- defaultMessage: 'Last value of {name}',
- values: {
- name,
- },
- });
+function ofName(name: string, timeShift: string | undefined) {
+ return adjustTimeScaleLabelSuffix(
+ i18n.translate('xpack.lens.indexPattern.lastValueOf', {
+ defaultMessage: 'Last value of {name}',
+ values: {
+ name,
+ },
+ }),
+ undefined,
+ undefined,
+ undefined,
+ timeShift
+ );
}
const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']);
@@ -96,7 +103,8 @@ export const lastValueOperation: OperationDefinition ofName(getSafeName(column.sourceField, indexPattern)),
+ getDefaultLabel: (column, indexPattern) =>
+ ofName(getSafeName(column.sourceField, indexPattern), column.timeShift),
input: 'field',
onFieldChange: (oldColumn, field) => {
const newParams = { ...oldColumn.params };
@@ -107,7 +115,7 @@ export const lastValueOperation: OperationDefinition {
return buildExpressionFunction('aggTopHit', {
id: columnId,
@@ -184,6 +194,8 @@ export const lastValueOperation: OperationDefinition>({
}) {
const labelLookup = (name: string, column?: BaseIndexPatternColumn) => {
const label = ofName(name);
- if (!optionalTimeScaling) {
- return label;
- }
- return adjustTimeScaleLabelSuffix(label, undefined, column?.timeScale);
+ return adjustTimeScaleLabelSuffix(
+ label,
+ undefined,
+ optionalTimeScaling ? column?.timeScale : undefined,
+ undefined,
+ column?.timeShift
+ );
};
return {
@@ -104,6 +107,7 @@ function buildMetricOperation>({
scale: 'ratio',
timeScale: optionalTimeScaling ? previousColumn?.timeScale : undefined,
filter: getFilter(previousColumn, columnParams),
+ timeShift: previousColumn?.timeShift,
params: getFormatFromPreviousColumn(previousColumn),
} as T;
},
@@ -120,11 +124,14 @@ function buildMetricOperation>({
enabled: true,
schema: 'metric',
field: column.sourceField,
+ // time shift is added to wrapping aggFilteredMetric if filter is set
+ timeShift: column.filter ? undefined : column.timeShift,
}).toAst();
},
getErrorMessage: (layer, columnId, indexPattern) =>
getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
filterable: true,
+ shiftable: true,
} as OperationDefinition;
}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx
index 4c09ae4ed8c47b..aa8f951d46b4f2 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx
@@ -19,6 +19,7 @@ import {
getFilter,
} from './helpers';
import { FieldBasedIndexPatternColumn } from './column_types';
+import { adjustTimeScaleLabelSuffix } from '../time_scale_utils';
import { useDebounceWithOptions } from '../../../shared_components';
export interface PercentileIndexPatternColumn extends FieldBasedIndexPatternColumn {
@@ -34,12 +35,18 @@ export interface PercentileIndexPatternColumn extends FieldBasedIndexPatternColu
};
}
-function ofName(name: string, percentile: number) {
- return i18n.translate('xpack.lens.indexPattern.percentileOf', {
- defaultMessage:
- '{percentile, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} percentile of {name}',
- values: { name, percentile },
- });
+function ofName(name: string, percentile: number, timeShift: string | undefined) {
+ return adjustTimeScaleLabelSuffix(
+ i18n.translate('xpack.lens.indexPattern.percentileOf', {
+ defaultMessage:
+ '{percentile, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} percentile of {name}',
+ values: { name, percentile },
+ }),
+ undefined,
+ undefined,
+ undefined,
+ timeShift
+ );
}
const DEFAULT_PERCENTILE_VALUE = 95;
@@ -54,6 +61,7 @@ export const percentileOperation: OperationDefinition {
if (supportedFieldTypes.includes(fieldType) && aggregatable && !aggregationRestrictions) {
return {
@@ -74,7 +82,11 @@ export const percentileOperation: OperationDefinition
- ofName(getSafeName(column.sourceField, indexPattern), column.params.percentile),
+ ofName(
+ getSafeName(column.sourceField, indexPattern),
+ column.params.percentile,
+ column.timeShift
+ ),
buildColumn: ({ field, previousColumn, indexPattern }, columnParams) => {
const existingPercentileParam =
previousColumn?.operationType === 'percentile' &&
@@ -84,13 +96,18 @@ export const percentileOperation: OperationDefinition {
return {
...oldColumn,
- label: ofName(field.displayName, oldColumn.params.percentile),
+ label: ofName(field.displayName, oldColumn.params.percentile, oldColumn.timeShift),
sourceField: field.name,
};
},
@@ -113,6 +130,8 @@ export const percentileOperation: OperationDefinition operationDefinitionMap[col.operationType].shiftable)
+ .map((col) => col.timeShift || '')
+ ).length > 1;
+ if (!hasMultipleShifts) {
+ return undefined;
+ }
+ return {
+ message: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShifts', {
+ defaultMessage:
+ 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.',
+ }),
+ fixAction: {
+ label: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel', {
+ defaultMessage: 'Use filters',
+ }),
+ newState: async (core: CoreStart, frame: FramePublicAPI, layerId: string) => {
+ const currentColumn = layer.columns[columnId] as TermsIndexPatternColumn;
+ const fieldName = currentColumn.sourceField;
+ const activeDataFieldNameMatch =
+ frame.activeData?.[layerId].columns.find(({ id }) => id === columnId)?.meta.field ===
+ fieldName;
+ let currentTerms = uniq(
+ frame.activeData?.[layerId].rows
+ .map((row) => row[columnId] as string)
+ .filter((term) => typeof term === 'string' && term !== '__other__') || []
+ );
+ if (!activeDataFieldNameMatch || currentTerms.length === 0) {
+ const response: FieldStatsResponse = await core.http.post(
+ `/api/lens/index_stats/${indexPattern.id}/field`,
+ {
+ body: JSON.stringify({
+ fieldName,
+ dslQuery: esQuery.buildEsQuery(
+ indexPattern as IIndexPattern,
+ frame.query,
+ frame.filters,
+ esQuery.getEsQueryConfig(core.uiSettings)
+ ),
+ fromDate: frame.dateRange.fromDate,
+ toDate: frame.dateRange.toDate,
+ size: currentColumn.params.size,
+ }),
+ }
+ );
+ currentTerms = response.topValues?.buckets.map(({ key }) => String(key)) || [];
+ }
+ return {
+ ...layer,
+ columns: {
+ ...layer.columns,
+ [columnId]: {
+ label: i18n.translate('xpack.lens.indexPattern.pinnedTopValuesLabel', {
+ defaultMessage: 'Filters of {field}',
+ values: {
+ field: fieldName,
+ },
+ }),
+ customLabel: true,
+ isBucketed: layer.columns[columnId].isBucketed,
+ dataType: 'string',
+ operationType: 'filters',
+ params: {
+ filters:
+ currentTerms.length > 0
+ ? currentTerms.map((term) => ({
+ input: {
+ query: `${fieldName}: "${term}"`,
+ language: 'kuery',
+ },
+ label: term,
+ }))
+ : [
+ {
+ input: {
+ query: '*',
+ language: 'kuery',
+ },
+ label: defaultLabel,
+ },
+ ],
+ },
+ } as FiltersIndexPatternColumn,
+ },
+ };
+ },
+ },
+ };
+}
+
const DEFAULT_SIZE = 3;
const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']);
@@ -90,7 +195,13 @@ export const termsOperation: OperationDefinition
- getInvalidFieldMessage(layer.columns[columnId] as FieldBasedIndexPatternColumn, indexPattern),
+ [
+ ...(getInvalidFieldMessage(
+ layer.columns[columnId] as FieldBasedIndexPatternColumn,
+ indexPattern
+ ) || []),
+ getDisallowedTermsMessage(layer, columnId, indexPattern) || '',
+ ].filter(Boolean),
isTransferable: (column, newIndexPattern) => {
const newField = newIndexPattern.getFieldByName(column.sourceField);
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx
index aab957c8ecebe4..b272d1703377ce 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx
@@ -9,7 +9,12 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { shallow, mount } from 'enzyme';
import { EuiFieldNumber, EuiSelect, EuiSwitch, EuiSwitchEvent } from '@elastic/eui';
-import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
+import type {
+ IUiSettingsClient,
+ SavedObjectsClientContract,
+ HttpSetup,
+ CoreStart,
+} from 'kibana/public';
import type { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks';
import { createMockedIndexPattern } from '../../../mocks';
@@ -17,6 +22,7 @@ import { ValuesInput } from './values_input';
import type { TermsIndexPatternColumn } from '.';
import { termsOperation } from '../index';
import { IndexPattern, IndexPatternLayer } from '../../../types';
+import { FramePublicAPI } from '../../../../types';
const uiSettingsMock = {} as IUiSettingsClient;
@@ -986,8 +992,8 @@ describe('terms', () => {
indexPatternId: '',
};
});
- it('returns undefined if sourceField exists in index pattern', () => {
- expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual(undefined);
+ it('returns empty array', () => {
+ expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([]);
});
it('returns error message if the sourceField does not exist in index pattern', () => {
layer = {
@@ -1003,5 +1009,102 @@ describe('terms', () => {
'Field notExisting was not found',
]);
});
+
+ describe('time shift error', () => {
+ beforeEach(() => {
+ layer = {
+ ...layer,
+ columnOrder: ['col1', 'col2', 'col3'],
+ columns: {
+ ...layer.columns,
+ col2: {
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'count',
+ label: 'Count',
+ sourceField: 'document',
+ },
+ col3: {
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'count',
+ label: 'Count',
+ sourceField: 'document',
+ timeShift: '1d',
+ },
+ },
+ };
+ });
+ it('returns error message if two time shifts are used together with terms', () => {
+ expect(termsOperation.getErrorMessage!(layer, 'col1', indexPattern)).toEqual([
+ expect.objectContaining({
+ message:
+ 'In a single layer, you are unable to combine metrics with different time shifts and dynamic top values. Use the same time shift value for all metrics, or use filters instead of top values.',
+ }),
+ ]);
+ });
+ it('returns fix action which calls field information endpoint and creates a pinned top values', async () => {
+ const errorMessage = termsOperation.getErrorMessage!(layer, 'col1', indexPattern)![0];
+ const fixAction = (typeof errorMessage === 'object'
+ ? errorMessage.fixAction!.newState
+ : undefined)!;
+ const coreMock = ({
+ uiSettings: {
+ get: () => undefined,
+ },
+ http: {
+ post: jest.fn(() =>
+ Promise.resolve({
+ topValues: {
+ buckets: [
+ {
+ key: 'A',
+ },
+ {
+ key: 'B',
+ },
+ ],
+ },
+ })
+ ),
+ },
+ } as unknown) as CoreStart;
+ const newLayer = await fixAction(
+ coreMock,
+ ({
+ query: { language: 'kuery', query: 'a: b' },
+ filters: [],
+ dateRange: {
+ fromDate: '2020',
+ toDate: '2021',
+ },
+ } as unknown) as FramePublicAPI,
+ 'first'
+ );
+ expect(newLayer.columns.col1).toEqual(
+ expect.objectContaining({
+ operationType: 'filters',
+ params: {
+ filters: [
+ {
+ input: {
+ language: 'kuery',
+ query: 'bytes: "A"',
+ },
+ label: 'A',
+ },
+ {
+ input: {
+ language: 'kuery',
+ query: 'bytes: "B"',
+ },
+ label: 'B',
+ },
+ ],
+ },
+ })
+ );
+ });
+ });
});
});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts
index 4dd56d2de1144a..a53a6e00810b89 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts
@@ -2589,7 +2589,10 @@ describe('state_helpers', () => {
col1: { operationType: 'average' },
},
},
- indexPattern
+ indexPattern,
+ {},
+ '1',
+ {}
);
expect(mock).toHaveBeenCalled();
expect(errors).toHaveLength(1);
@@ -2608,7 +2611,10 @@ describe('state_helpers', () => {
{ operationType: 'testReference', references: [] },
},
},
- indexPattern
+ indexPattern,
+ {},
+ '1',
+ {}
);
expect(mock).toHaveBeenCalled();
expect(errors).toHaveLength(1);
@@ -2637,7 +2643,10 @@ describe('state_helpers', () => {
col1: { operationType: 'testIncompleteReference' },
},
},
- indexPattern
+ indexPattern,
+ {},
+ '1',
+ {}
);
expect(savedRef).toHaveBeenCalled();
expect(incompleteRef).not.toHaveBeenCalled();
@@ -2659,7 +2668,10 @@ describe('state_helpers', () => {
{ operationType: 'testReference', references: [] },
},
},
- indexPattern
+ indexPattern,
+ {},
+ '1',
+ {}
);
expect(mock).toHaveBeenCalledWith(
{
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 92452a11e94c16..b42cdbd24e656b 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
@@ -6,8 +6,12 @@
*/
import { partition, mapValues, pickBy } from 'lodash';
-import { getSortScoreByPriority } from './operations';
-import type { OperationMetadata, VisualizationDimensionGroupConfig } from '../../types';
+import { CoreStart } from 'kibana/public';
+import type {
+ FramePublicAPI,
+ OperationMetadata,
+ VisualizationDimensionGroupConfig,
+} from '../../types';
import {
operationDefinitionMap,
operationDefinitions,
@@ -15,7 +19,13 @@ import {
IndexPatternColumn,
RequiredReference,
} from './definitions';
-import type { IndexPattern, IndexPatternField, IndexPatternLayer } from '../types';
+import type {
+ IndexPattern,
+ IndexPatternField,
+ IndexPatternLayer,
+ IndexPatternPrivateState,
+} from '../types';
+import { getSortScoreByPriority } from './operations';
import { generateId } from '../../id_generator';
import { ReferenceBasedIndexPatternColumn } from './definitions/column_types';
import { FormulaIndexPatternColumn, regenerateLayerFromAst } from './definitions/formula';
@@ -674,6 +684,7 @@ function applyReferenceTransition({
// drop the filter for the referenced column because the wrapping operation
// is filterable as well and will handle it one level higher.
filter: operationDefinition.filterable ? undefined : previousColumn.filter,
+ timeShift: operationDefinition.shiftable ? undefined : previousColumn.timeShift,
},
},
};
@@ -1135,20 +1146,65 @@ export function updateLayerIndexPattern(
* - All columns have complete references
* - All column references are valid
* - All prerequisites are met
+ * - If timeshift is used, terms go before date histogram
+ * - If timeshift is used, only a single date histogram can be used
*/
export function getErrorMessages(
layer: IndexPatternLayer,
- indexPattern: IndexPattern
-): string[] | undefined {
- const errors: string[] = Object.entries(layer.columns)
+ indexPattern: IndexPattern,
+ state: IndexPatternPrivateState,
+ layerId: string,
+ core: CoreStart
+):
+ | Array<
+ | string
+ | {
+ message: string;
+ fixAction?: {
+ label: string;
+ newState: (frame: FramePublicAPI) => Promise;
+ };
+ }
+ >
+ | undefined {
+ const errors = Object.entries(layer.columns)
.flatMap(([columnId, column]) => {
const def = operationDefinitionMap[column.operationType];
if (def.getErrorMessage) {
return def.getErrorMessage(layer, columnId, indexPattern, operationDefinitionMap);
}
})
+ .map((errorMessage) => {
+ if (typeof errorMessage !== 'object') {
+ return errorMessage;
+ }
+ return {
+ ...errorMessage,
+ fixAction: errorMessage.fixAction
+ ? {
+ ...errorMessage.fixAction,
+ newState: async (frame: FramePublicAPI) => ({
+ ...state,
+ layers: {
+ ...state.layers,
+ [layerId]: await errorMessage.fixAction!.newState(core, frame, layerId),
+ },
+ }),
+ }
+ : undefined,
+ };
+ })
// remove the undefined values
- .filter((v: string | undefined): v is string => v != null);
+ .filter((v) => v != null) as Array<
+ | string
+ | {
+ message: string;
+ fixAction?: {
+ label: string;
+ newState: (framePublicAPI: FramePublicAPI) => Promise;
+ };
+ }
+ >;
return errors.length ? errors : undefined;
}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts
index d53940aa585fd3..152fcaa457c3bc 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.test.ts
@@ -15,29 +15,84 @@ export const DEFAULT_TIME_SCALE = 's' as TimeScaleUnit;
describe('time scale utils', () => {
describe('adjustTimeScaleLabelSuffix', () => {
it('should should remove existing suffix', () => {
- expect(adjustTimeScaleLabelSuffix('abc per second', 's', undefined)).toEqual('abc');
- expect(adjustTimeScaleLabelSuffix('abc per hour', 'h', undefined)).toEqual('abc');
+ expect(
+ adjustTimeScaleLabelSuffix('abc per second', 's', undefined, undefined, undefined)
+ ).toEqual('abc');
+ expect(
+ adjustTimeScaleLabelSuffix('abc per hour', 'h', undefined, undefined, undefined)
+ ).toEqual('abc');
+ expect(adjustTimeScaleLabelSuffix('abc -3d', undefined, undefined, '3d', undefined)).toEqual(
+ 'abc'
+ );
+ expect(
+ adjustTimeScaleLabelSuffix('abc per hour -3d', 'h', undefined, '3d', undefined)
+ ).toEqual('abc');
});
it('should add suffix', () => {
- expect(adjustTimeScaleLabelSuffix('abc', undefined, 's')).toEqual('abc per second');
- expect(adjustTimeScaleLabelSuffix('abc', undefined, 'd')).toEqual('abc per day');
+ expect(adjustTimeScaleLabelSuffix('abc', undefined, 's', undefined, undefined)).toEqual(
+ 'abc per second'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc', undefined, 'd', undefined, undefined)).toEqual(
+ 'abc per day'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc', undefined, undefined, undefined, '12h')).toEqual(
+ 'abc -12h'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc', undefined, 'h', undefined, '12h')).toEqual(
+ 'abc per hour -12h'
+ );
+ });
+
+ it('should add and remove at the same time', () => {
+ expect(adjustTimeScaleLabelSuffix('abc per hour', 'h', undefined, undefined, '1d')).toEqual(
+ 'abc -1d'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc -1d', undefined, 'h', '1d', undefined)).toEqual(
+ 'abc per hour'
+ );
});
it('should change suffix', () => {
- expect(adjustTimeScaleLabelSuffix('abc per second', 's', 'd')).toEqual('abc per day');
- expect(adjustTimeScaleLabelSuffix('abc per day', 'd', 's')).toEqual('abc per second');
+ expect(adjustTimeScaleLabelSuffix('abc per second', 's', 'd', undefined, undefined)).toEqual(
+ 'abc per day'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc per day', 'd', 's', undefined, undefined)).toEqual(
+ 'abc per second'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc per day -3h', 'd', 's', '3h', '3h')).toEqual(
+ 'abc per second -3h'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc per day -3h', 'd', 'd', '3h', '4h')).toEqual(
+ 'abc per day -4h'
+ );
});
it('should keep current state', () => {
- expect(adjustTimeScaleLabelSuffix('abc', undefined, undefined)).toEqual('abc');
- expect(adjustTimeScaleLabelSuffix('abc per day', 'd', 'd')).toEqual('abc per day');
+ expect(adjustTimeScaleLabelSuffix('abc', undefined, undefined, undefined, undefined)).toEqual(
+ 'abc'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc per day', 'd', 'd', undefined, undefined)).toEqual(
+ 'abc per day'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc -1h', undefined, undefined, '1h', '1h')).toEqual(
+ 'abc -1h'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc per day -1h', 'd', 'd', '1h', '1h')).toEqual(
+ 'abc per day -1h'
+ );
});
it('should not fail on inconsistent input', () => {
- expect(adjustTimeScaleLabelSuffix('abc', 's', undefined)).toEqual('abc');
- expect(adjustTimeScaleLabelSuffix('abc', 's', 'd')).toEqual('abc per day');
- expect(adjustTimeScaleLabelSuffix('abc per day', 's', undefined)).toEqual('abc per day');
+ expect(adjustTimeScaleLabelSuffix('abc', 's', undefined, undefined, undefined)).toEqual(
+ 'abc'
+ );
+ expect(adjustTimeScaleLabelSuffix('abc', 's', 'd', undefined, undefined)).toEqual(
+ 'abc per day'
+ );
+ expect(
+ adjustTimeScaleLabelSuffix('abc per day', 's', undefined, undefined, undefined)
+ ).toEqual('abc per day');
});
});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts
index 07806a32665dda..a0b61060b9f3ad 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/time_scale_utils.ts
@@ -12,24 +12,36 @@ import type { IndexPatternColumn } from './definitions';
export const DEFAULT_TIME_SCALE = 's' as TimeScaleUnit;
+function getSuffix(scale: TimeScaleUnit | undefined, shift: string | undefined) {
+ return (
+ (shift || scale ? ' ' : '') +
+ (scale ? unitSuffixesLong[scale] : '') +
+ (shift && scale ? ' ' : '') +
+ (shift ? `-${shift}` : '')
+ );
+}
+
export function adjustTimeScaleLabelSuffix(
oldLabel: string,
previousTimeScale: TimeScaleUnit | undefined,
- newTimeScale: TimeScaleUnit | undefined
+ newTimeScale: TimeScaleUnit | undefined,
+ previousShift: string | undefined,
+ newShift: string | undefined
) {
let cleanedLabel = oldLabel;
// remove added suffix if column had a time scale previously
- if (previousTimeScale) {
- const suffixPosition = oldLabel.lastIndexOf(` ${unitSuffixesLong[previousTimeScale]}`);
+ if (previousTimeScale || previousShift) {
+ const suffix = getSuffix(previousTimeScale, previousShift);
+ const suffixPosition = oldLabel.lastIndexOf(suffix);
if (suffixPosition !== -1) {
cleanedLabel = oldLabel.substring(0, suffixPosition);
}
}
- if (!newTimeScale) {
+ if (!newTimeScale && !newShift) {
return cleanedLabel;
}
// add new suffix if column has a time scale now
- return `${cleanedLabel} ${unitSuffixesLong[newTimeScale]}`;
+ return `${cleanedLabel}${getSuffix(newTimeScale, newShift)}`;
}
export function adjustTimeScaleOnOtherColumnChange(
@@ -54,6 +66,12 @@ export function adjustTimeScaleOnOtherColumnChange
return {
...column,
timeScale: undefined,
- label: adjustTimeScaleLabelSuffix(column.label, column.timeScale, undefined),
+ label: adjustTimeScaleLabelSuffix(
+ column.label,
+ column.timeScale,
+ undefined,
+ column.timeShift,
+ column.timeShift
+ ),
};
}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts
index 430e139a85ccae..49bec5f58c29cc 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts
@@ -54,6 +54,22 @@ function getExpressionForLayer(
}
});
}
+
+ if (
+ 'references' in column &&
+ rootDef.shiftable &&
+ rootDef.input === 'fullReference' &&
+ column.timeShift
+ ) {
+ // inherit time shift to all referenced operations
+ column.references.forEach((referenceColumnId) => {
+ const referencedColumn = columns[referenceColumnId];
+ const referenceDef = operationDefinitionMap[column.operationType];
+ if (referenceDef.shiftable) {
+ columns[referenceColumnId] = { ...referencedColumn, timeShift: column.timeShift };
+ }
+ });
+ }
});
const columnEntries = columnOrder.map((colId) => [colId, columns[colId]] as const);
@@ -106,6 +122,7 @@ function getExpressionForLayer(
}),
]),
customMetric: buildExpression({ type: 'expression', chain: [aggAst] }),
+ timeShift: col.timeShift,
}
).toAst();
}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts
index 23c7adb86d34fa..a9e24c70ab8acd 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts
@@ -61,14 +61,14 @@ export function isColumnInvalid(
'references' in column &&
Boolean(getReferencesErrors(layer, column, indexPattern).filter(Boolean).length);
- return (
- !!operationDefinition.getErrorMessage?.(
- layer,
- columnId,
- indexPattern,
- operationDefinitionMap
- ) || referencesHaveErrors
+ const operationErrorMessages = operationDefinition.getErrorMessage?.(
+ layer,
+ columnId,
+ indexPattern,
+ operationDefinitionMap
);
+
+ return (operationErrorMessages && operationErrorMessages.length > 0) || referencesHaveErrors;
}
function getReferencesErrors(
diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts
index 984fbf5555949b..854466956ceed7 100644
--- a/x-pack/plugins/lens/public/types.ts
+++ b/x-pack/plugins/lens/public/types.ts
@@ -224,8 +224,18 @@ export interface Datasource {
getPublicAPI: (props: PublicAPIProps) => DatasourcePublicAPI;
getErrorMessages: (
state: T,
- layersGroups?: Record
- ) => Array<{ shortMessage: string; longMessage: string }> | undefined;
+ layersGroups?: Record,
+ dateRange?: {
+ fromDate: string;
+ toDate: string;
+ }
+ ) =>
+ | Array<{
+ shortMessage: string;
+ longMessage: string;
+ fixAction?: { label: string; newState: () => Promise };
+ }>
+ | undefined;
/**
* uniqueLabels of dimensions exposed for aria-labels of dragged dimensions
*/
@@ -234,6 +244,10 @@ export interface Datasource {
* Check the internal state integrity and returns a list of missing references
*/
checkIntegrity: (state: T) => string[];
+ /**
+ * The frame calls this function to display warnings about visualization
+ */
+ getWarningMessages?: (state: T, frame: FramePublicAPI) => React.ReactNode[] | undefined;
}
/**
@@ -673,7 +687,12 @@ export interface Visualization {
getErrorMessages: (
state: T,
datasourceLayers?: Record
- ) => Array<{ shortMessage: string; longMessage: string }> | undefined;
+ ) =>
+ | Array<{
+ shortMessage: string;
+ longMessage: string;
+ }>
+ | undefined;
/**
* The frame calls this function to display warnings about visualization
diff --git a/x-pack/plugins/lens/server/routes/field_stats.ts b/x-pack/plugins/lens/server/routes/field_stats.ts
index 6cddd2c60f4165..6b7e197a4d5617 100644
--- a/x-pack/plugins/lens/server/routes/field_stats.ts
+++ b/x-pack/plugins/lens/server/routes/field_stats.ts
@@ -31,6 +31,7 @@ export async function initFieldsRoute(setup: CoreSetup) {
fromDate: schema.string(),
toDate: schema.string(),
fieldName: schema.string(),
+ size: schema.maybe(schema.number()),
},
{ unknowns: 'allow' }
),
@@ -38,7 +39,7 @@ export async function initFieldsRoute(setup: CoreSetup) {
},
async (context, req, res) => {
const requestClient = context.core.elasticsearch.client.asCurrentUser;
- const { fromDate, toDate, fieldName, dslQuery } = req.body;
+ const { fromDate, toDate, fieldName, dslQuery, size } = req.body;
const [{ savedObjects, elasticsearch }, { data }] = await setup.getStartServices();
const savedObjectsClient = savedObjects.getScopedClient(req);
@@ -112,7 +113,7 @@ export async function initFieldsRoute(setup: CoreSetup) {
}
return res.ok({
- body: await getStringSamples(search, field),
+ body: await getStringSamples(search, field, size),
});
} catch (e) {
if (e instanceof SavedObjectNotFound) {
@@ -245,7 +246,8 @@ export async function getNumberHistogram(
export async function getStringSamples(
aggSearchWithBody: (aggs: Record) => unknown,
- field: IFieldType
+ field: IFieldType,
+ size = 10
): Promise {
const fieldRef = getFieldRef(field);
@@ -257,7 +259,7 @@ export async function getStringSamples(
top_values: {
terms: {
...fieldRef,
- size: 10,
+ size,
},
},
},
diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts
index d0466b8814fec1..bff0b590a8e68b 100644
--- a/x-pack/test/functional/apps/lens/index.ts
+++ b/x-pack/test/functional/apps/lens/index.ts
@@ -36,6 +36,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./persistent_context'));
loadTestFile(require.resolve('./colors'));
loadTestFile(require.resolve('./chart_data'));
+ loadTestFile(require.resolve('./time_shift'));
loadTestFile(require.resolve('./drag_and_drop'));
loadTestFile(require.resolve('./geo_field'));
loadTestFile(require.resolve('./lens_reporting'));
diff --git a/x-pack/test/functional/apps/lens/time_shift.ts b/x-pack/test/functional/apps/lens/time_shift.ts
new file mode 100644
index 00000000000000..57c2fc194d0c05
--- /dev/null
+++ b/x-pack/test/functional/apps/lens/time_shift.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import expect from '@kbn/expect';
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function ({ getService, getPageObjects }: FtrProviderContext) {
+ const PageObjects = getPageObjects(['visualize', 'lens', 'common', 'header']);
+
+ describe('time shift', () => {
+ it('should able to configure a shifted metric', async () => {
+ await PageObjects.visualize.navigateToNewVisualization();
+ await PageObjects.visualize.clickVisType('lens');
+ await PageObjects.lens.goToTimeRange();
+ await PageObjects.lens.switchToVisualization('lnsDatatable');
+ await PageObjects.lens.configureDimension({
+ dimension: 'lnsDatatable_rows > lns-empty-dimension',
+ operation: 'date_histogram',
+ field: '@timestamp',
+ });
+ await PageObjects.lens.configureDimension({
+ dimension: 'lnsDatatable_metrics > lns-empty-dimension',
+ operation: 'median',
+ field: 'bytes',
+ });
+ await PageObjects.lens.openDimensionEditor('lnsDatatable_metrics > lns-dimensionTrigger');
+ await PageObjects.lens.enableTimeShift();
+ await PageObjects.lens.setTimeShift('6h');
+
+ await PageObjects.lens.waitForVisualization();
+ expect(await PageObjects.lens.getDatatableCellText(0, 1)).to.eql('5,994');
+ });
+
+ it('should able to configure a regular metric next to a shifted metric', async () => {
+ await PageObjects.lens.closeDimensionEditor();
+ await PageObjects.lens.configureDimension({
+ dimension: 'lnsDatatable_metrics > lns-empty-dimension',
+ operation: 'average',
+ field: 'bytes',
+ });
+
+ await PageObjects.lens.waitForVisualization();
+
+ expect(await PageObjects.lens.getDatatableCellText(2, 1)).to.eql('5,994');
+ expect(await PageObjects.lens.getDatatableCellText(2, 2)).to.eql('5,722.622');
+ });
+
+ it('should show an error if terms is used and provide a fix action', async () => {
+ await PageObjects.lens.configureDimension({
+ dimension: 'lnsDatatable_rows > lns-empty-dimension',
+ operation: 'terms',
+ field: 'ip',
+ });
+
+ expect(await PageObjects.lens.hasFixAction()).to.be(true);
+ await PageObjects.lens.useFixAction();
+
+ expect(await PageObjects.lens.getDatatableCellText(2, 2)).to.eql('5,541.5');
+ expect(await PageObjects.lens.getDatatableCellText(2, 3)).to.eql('3,628');
+
+ expect(await PageObjects.lens.getDatatableHeaderText(0)).to.eql('Filters of ip');
+ });
+ });
+}
diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts
index b16944cd730606..0b88ecca247c5f 100644
--- a/x-pack/test/functional/page_objects/lens_page.ts
+++ b/x-pack/test/functional/page_objects/lens_page.ts
@@ -362,6 +362,25 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
});
},
+ async enableTimeShift() {
+ await testSubjects.click('indexPattern-advanced-popover');
+ await retry.try(async () => {
+ await testSubjects.click('indexPattern-time-shift-enable');
+ });
+ },
+
+ async setTimeShift(shift: string) {
+ await comboBox.setCustom('indexPattern-dimension-time-shift', shift);
+ },
+
+ async hasFixAction() {
+ return await testSubjects.exists('errorFixAction');
+ },
+
+ async useFixAction() {
+ await testSubjects.click('errorFixAction');
+ },
+
// closes the dimension editor flyout
async closeDimensionEditor() {
await retry.try(async () => {
From ccc14856063fca25e838e8a631fe9f4fa96fed5f Mon Sep 17 00:00:00 2001
From: Pierre Gayvallet
Date: Wed, 2 Jun 2021 16:07:34 +0200
Subject: [PATCH 06/77] Fix newsfeed unread notifications always on when
reloading Kibana (#100357)
* fix the implementation
* add unit tests
* add API unit tests
* fix public interface
* address review comments
* name convertItem to localizeItem
* use fetch instead of core.http and add tests
---
src/plugins/newsfeed/common/constants.ts | 5 -
.../components/newsfeed_header_nav_button.tsx | 44 +-
.../newsfeed/public/lib/api.test.mocks.ts | 20 +
src/plugins/newsfeed/public/lib/api.test.ts | 749 +++---------------
src/plugins/newsfeed/public/lib/api.ts | 202 +----
.../newsfeed/public/lib/convert_items.test.ts | 144 ++++
.../newsfeed/public/lib/convert_items.ts | 72 ++
.../newsfeed/public/lib/driver.mock.ts | 22 +
.../newsfeed/public/lib/driver.test.mocks.ts | 12 +
.../newsfeed/public/lib/driver.test.ts | 133 ++++
src/plugins/newsfeed/public/lib/driver.ts | 79 ++
.../newsfeed/public/lib/storage.mock.ts | 26 +
.../newsfeed/public/lib/storage.test.ts | 165 ++++
src/plugins/newsfeed/public/lib/storage.ts | 89 +++
src/plugins/newsfeed/public/plugin.tsx | 40 +-
15 files changed, 936 insertions(+), 866 deletions(-)
create mode 100644 src/plugins/newsfeed/public/lib/api.test.mocks.ts
create mode 100644 src/plugins/newsfeed/public/lib/convert_items.test.ts
create mode 100644 src/plugins/newsfeed/public/lib/convert_items.ts
create mode 100644 src/plugins/newsfeed/public/lib/driver.mock.ts
create mode 100644 src/plugins/newsfeed/public/lib/driver.test.mocks.ts
create mode 100644 src/plugins/newsfeed/public/lib/driver.test.ts
create mode 100644 src/plugins/newsfeed/public/lib/driver.ts
create mode 100644 src/plugins/newsfeed/public/lib/storage.mock.ts
create mode 100644 src/plugins/newsfeed/public/lib/storage.test.ts
create mode 100644 src/plugins/newsfeed/public/lib/storage.ts
diff --git a/src/plugins/newsfeed/common/constants.ts b/src/plugins/newsfeed/common/constants.ts
index 7ff078e82810b0..6ba5e07ea873eb 100644
--- a/src/plugins/newsfeed/common/constants.ts
+++ b/src/plugins/newsfeed/common/constants.ts
@@ -7,11 +7,6 @@
*/
export const NEWSFEED_FALLBACK_LANGUAGE = 'en';
-export const NEWSFEED_FALLBACK_FETCH_INTERVAL = 86400000; // 1 day
-export const NEWSFEED_FALLBACK_MAIN_INTERVAL = 120000; // 2 minutes
-export const NEWSFEED_LAST_FETCH_STORAGE_KEY = 'newsfeed.lastfetchtime';
-export const NEWSFEED_HASH_SET_STORAGE_KEY = 'newsfeed.hashes';
-
export const NEWSFEED_DEFAULT_SERVICE_BASE_URL = 'https://feeds.elastic.co';
export const NEWSFEED_DEV_SERVICE_BASE_URL = 'https://feeds-staging.elastic.co';
export const NEWSFEED_DEFAULT_SERVICE_PATH = '/kibana/v{VERSION}.json';
diff --git a/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx b/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx
index 142d4286b363bf..7060adcc2a4ec0 100644
--- a/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx
+++ b/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx
@@ -6,10 +6,10 @@
* Side Public License, v 1.
*/
-import React, { useState, Fragment, useEffect } from 'react';
-import * as Rx from 'rxjs';
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { EuiHeaderSectionItemButton, EuiIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
+import type { NewsfeedApi } from '../lib/api';
import { NewsfeedFlyout } from './flyout_list';
import { FetchResult } from '../types';
@@ -17,46 +17,44 @@ export interface INewsfeedContext {
setFlyoutVisible: React.Dispatch>;
newsFetchResult: FetchResult | void | null;
}
-export const NewsfeedContext = React.createContext({} as INewsfeedContext);
-export type NewsfeedApiFetchResult = Rx.Observable;
+export const NewsfeedContext = React.createContext({} as INewsfeedContext);
export interface Props {
- apiFetchResult: NewsfeedApiFetchResult;
+ newsfeedApi: NewsfeedApi;
}
-export const NewsfeedNavButton = ({ apiFetchResult }: Props) => {
- const [showBadge, setShowBadge] = useState(false);
+export const NewsfeedNavButton = ({ newsfeedApi }: Props) => {
const [flyoutVisible, setFlyoutVisible] = useState(false);
const [newsFetchResult, setNewsFetchResult] = useState(null);
+ const hasNew = useMemo(() => {
+ return newsFetchResult ? newsFetchResult.hasNew : false;
+ }, [newsFetchResult]);
useEffect(() => {
- function handleStatusChange(fetchResult: FetchResult | void | null) {
- if (fetchResult) {
- setShowBadge(fetchResult.hasNew);
- }
- setNewsFetchResult(fetchResult);
- }
-
- const subscription = apiFetchResult.subscribe((res) => handleStatusChange(res));
+ const subscription = newsfeedApi.fetchResults$.subscribe((results) => {
+ setNewsFetchResult(results);
+ });
return () => subscription.unsubscribe();
- }, [apiFetchResult]);
+ }, [newsfeedApi]);
- function showFlyout() {
- setShowBadge(false);
+ const showFlyout = useCallback(() => {
+ if (newsFetchResult) {
+ newsfeedApi.markAsRead(newsFetchResult.feedItems.map((item) => item.hash));
+ }
setFlyoutVisible(!flyoutVisible);
- }
+ }, [newsfeedApi, newsFetchResult, flyoutVisible]);
return (
-
+ <>
{
defaultMessage: 'Newsfeed menu - all items read',
})
}
- notification={showBadge ? true : null}
+ notification={hasNew ? true : null}
onClick={showFlyout}
>
{flyoutVisible ? : null}
-
+ >
);
};
diff --git a/src/plugins/newsfeed/public/lib/api.test.mocks.ts b/src/plugins/newsfeed/public/lib/api.test.mocks.ts
new file mode 100644
index 00000000000000..677bc203cbef3f
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/api.test.mocks.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { storageMock } from './storage.mock';
+import { driverMock } from './driver.mock';
+
+export const storageInstanceMock = storageMock.create();
+jest.doMock('./storage', () => ({
+ NewsfeedStorage: jest.fn().mockImplementation(() => storageInstanceMock),
+}));
+
+export const driverInstanceMock = driverMock.create();
+jest.doMock('./driver', () => ({
+ NewsfeedApiDriver: jest.fn().mockImplementation(() => driverInstanceMock),
+}));
diff --git a/src/plugins/newsfeed/public/lib/api.test.ts b/src/plugins/newsfeed/public/lib/api.test.ts
index e142ffb4f69897..a4894573932e6c 100644
--- a/src/plugins/newsfeed/public/lib/api.test.ts
+++ b/src/plugins/newsfeed/public/lib/api.test.ts
@@ -6,689 +6,120 @@
* Side Public License, v 1.
*/
-import { take, tap, toArray } from 'rxjs/operators';
-import { interval, race } from 'rxjs';
-import sinon, { stub } from 'sinon';
+import { driverInstanceMock, storageInstanceMock } from './api.test.mocks';
import moment from 'moment';
-import { HttpSetup } from 'src/core/public';
-import {
- NEWSFEED_HASH_SET_STORAGE_KEY,
- NEWSFEED_LAST_FETCH_STORAGE_KEY,
-} from '../../common/constants';
-import { ApiItem, NewsfeedItem, NewsfeedPluginBrowserConfig } from '../types';
-import { NewsfeedApiDriver, getApi } from './api';
+import { getApi } from './api';
+import { TestScheduler } from 'rxjs/testing';
+import { FetchResult, NewsfeedPluginBrowserConfig } from '../types';
+import { take } from 'rxjs/operators';
-const localStorageGet = sinon.stub();
-const sessionStoragetGet = sinon.stub();
+const kibanaVersion = '8.0.0';
+const newsfeedId = 'test';
-Object.defineProperty(window, 'localStorage', {
- value: {
- getItem: localStorageGet,
- setItem: stub(),
- },
- writable: true,
-});
-Object.defineProperty(window, 'sessionStorage', {
- value: {
- getItem: sessionStoragetGet,
- setItem: stub(),
- },
- writable: true,
-});
-
-jest.mock('uuid', () => ({
- v4: () => 'NEW_UUID',
-}));
-
-describe('NewsfeedApiDriver', () => {
- const kibanaVersion = '99.999.9-test_version'; // It'll remove the `-test_version` bit
- const userLanguage = 'en';
- const fetchInterval = 2000;
- const getDriver = () => new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval);
-
- afterEach(() => {
- sinon.reset();
- });
-
- describe('shouldFetch', () => {
- it('defaults to true', () => {
- const driver = getDriver();
- expect(driver.shouldFetch()).toBe(true);
- });
-
- it('returns true if last fetch time precedes page load time', () => {
- sessionStoragetGet.throws('Wrong key passed!');
- sessionStoragetGet
- .withArgs(`${NEWSFEED_LAST_FETCH_STORAGE_KEY}.NEW_UUID`)
- .returns(322642800000); // 1980-03-23
- const driver = getDriver();
- expect(driver.shouldFetch()).toBe(true);
- });
-
- it('returns false if last fetch time is recent enough', () => {
- sessionStoragetGet.throws('Wrong key passed!');
- sessionStoragetGet
- .withArgs(`${NEWSFEED_LAST_FETCH_STORAGE_KEY}.NEW_UUID`)
- .returns(3005017200000); // 2065-03-23
- const driver = getDriver();
- expect(driver.shouldFetch()).toBe(false);
- });
- });
-
- describe('updateHashes', () => {
- it('returns previous and current storage', () => {
- const driver = getDriver();
- const items: NewsfeedItem[] = [
- {
- title: 'Good news, everyone!',
- description: 'good item description',
- linkText: 'click here',
- linkUrl: 'about:blank',
- badge: 'test',
- publishOn: moment(1572489035150),
- expireOn: moment(1572489047858),
- hash: 'hash1oneoneoneone',
- },
- ];
- expect(driver.updateHashes(items)).toMatchInlineSnapshot(`
- Object {
- "current": Array [
- "hash1oneoneoneone",
- ],
- "previous": Array [],
- }
- `);
- });
-
- it('concatenates the previous hashes with the current', () => {
- localStorageGet.throws('Wrong key passed!');
- localStorageGet.withArgs(`${NEWSFEED_HASH_SET_STORAGE_KEY}.NEW_UUID`).returns('happyness');
- const driver = getDriver();
- const items: NewsfeedItem[] = [
- {
- title: 'Better news, everyone!',
- description: 'better item description',
- linkText: 'click there',
- linkUrl: 'about:blank',
- badge: 'concatentated',
- publishOn: moment(1572489035150),
- expireOn: moment(1572489047858),
- hash: 'three33hash',
- },
- ];
- expect(driver.updateHashes(items)).toMatchInlineSnapshot(`
- Object {
- "current": Array [
- "happyness",
- "three33hash",
- ],
- "previous": Array [
- "happyness",
- ],
- }
- `);
- });
- });
-
- it('Validates items for required fields', () => {
- const driver = getDriver();
- expect(driver.validateItem({})).toBe(false);
- expect(
- driver.validateItem({
- title: 'Gadzooks!',
- description: 'gadzooks item description',
- linkText: 'click here',
- linkUrl: 'about:blank',
- badge: 'test',
- publishOn: moment(1572489035150),
- expireOn: moment(1572489047858),
- hash: 'hash2twotwotwotwotwo',
- })
- ).toBe(true);
- expect(
- driver.validateItem({
- title: 'Gadzooks!',
- description: 'gadzooks item description',
- linkText: 'click here',
- linkUrl: 'about:blank',
- publishOn: moment(1572489035150),
- hash: 'hash2twotwotwotwotwo',
- })
- ).toBe(true);
- expect(
- driver.validateItem({
- title: 'Gadzooks!',
- description: 'gadzooks item description',
- linkText: 'click here',
- linkUrl: 'about:blank',
- publishOn: moment(1572489035150),
- // hash: 'hash2twotwotwotwotwo', // should fail because this is missing
- })
- ).toBe(false);
+const getTestScheduler = () =>
+ new TestScheduler((actual, expected) => {
+ expect(actual).toEqual(expected);
});
- describe('modelItems', () => {
- it('Models empty set with defaults', () => {
- const driver = getDriver();
- const apiItems: ApiItem[] = [];
- expect(driver.modelItems(apiItems)).toMatchInlineSnapshot(`
- Object {
- "error": null,
- "feedItems": Array [],
- "hasNew": false,
- "kibanaVersion": "99.999.9",
- }
- `);
- });
-
- it('Selects default language', () => {
- const driver = getDriver();
- const apiItems: ApiItem[] = [
- {
- title: {
- en: 'speaking English',
- es: 'habla Espanol',
- },
- description: {
- en: 'language test',
- es: 'idiomas',
- },
- languages: ['en', 'es'],
- link_text: {
- en: 'click here',
- es: 'aqui',
- },
- link_url: {
- en: 'xyzxyzxyz',
- es: 'abcabc',
- },
- badge: {
- en: 'firefighter',
- es: 'bombero',
- },
- publish_on: new Date('2014-10-31T04:23:47Z'),
- expire_on: new Date('2049-10-31T04:23:47Z'),
- hash: 'abcabc1231123123hash',
- },
- ];
- expect(driver.modelItems(apiItems)).toMatchObject({
- error: null,
- feedItems: [
- {
- badge: 'firefighter',
- description: 'language test',
- hash: 'abcabc1231',
- linkText: 'click here',
- linkUrl: 'xyzxyzxyz',
- title: 'speaking English',
- },
- ],
- hasNew: true,
- kibanaVersion: '99.999.9',
- });
- });
-
- it("Falls back to English when user language isn't present", () => {
- // Set Language to French
- const driver = new NewsfeedApiDriver(kibanaVersion, 'fr', fetchInterval);
- const apiItems: ApiItem[] = [
- {
- title: {
- en: 'speaking English',
- fr: 'Le Title',
- },
- description: {
- en: 'not French',
- fr: 'Le Description',
- },
- languages: ['en', 'fr'],
- link_text: {
- en: 'click here',
- fr: 'Le Link Text',
- },
- link_url: {
- en: 'xyzxyzxyz',
- fr: 'le_url',
- },
- badge: {
- en: 'firefighter',
- fr: 'le_badge',
- },
- publish_on: new Date('2014-10-31T04:23:47Z'),
- expire_on: new Date('2049-10-31T04:23:47Z'),
- hash: 'frfrfrfr1231123123hash',
- }, // fallback: no
- {
- title: {
- en: 'speaking English',
- es: 'habla Espanol',
- },
- description: {
- en: 'not French',
- es: 'no Espanol',
- },
- languages: ['en', 'es'],
- link_text: {
- en: 'click here',
- es: 'aqui',
- },
- link_url: {
- en: 'xyzxyzxyz',
- es: 'abcabc',
- },
- badge: {
- en: 'firefighter',
- es: 'bombero',
- },
- publish_on: new Date('2014-10-31T04:23:47Z'),
- expire_on: new Date('2049-10-31T04:23:47Z'),
- hash: 'enenenen1231123123hash',
- }, // fallback: yes
- ];
- expect(driver.modelItems(apiItems)).toMatchObject({
- error: null,
- feedItems: [
- {
- badge: 'le_badge',
- description: 'Le Description',
- hash: 'frfrfrfr12',
- linkText: 'Le Link Text',
- linkUrl: 'le_url',
- title: 'Le Title',
- },
- {
- badge: 'firefighter',
- description: 'not French',
- hash: 'enenenen12',
- linkText: 'click here',
- linkUrl: 'xyzxyzxyz',
- title: 'speaking English',
- },
- ],
- hasNew: true,
- kibanaVersion: '99.999.9',
- });
- });
-
- it('Models multiple items into an API FetchResult', () => {
- const driver = getDriver();
- const apiItems: ApiItem[] = [
- {
- title: {
- en: 'guess what',
- },
- description: {
- en: 'this tests the modelItems function',
- },
- link_text: {
- en: 'click here',
- },
- link_url: {
- en: 'about:blank',
- },
- publish_on: new Date('2014-10-31T04:23:47Z'),
- expire_on: new Date('2049-10-31T04:23:47Z'),
- hash: 'abcabc1231123123hash',
- },
- {
- title: {
- en: 'guess when',
- },
- description: {
- en: 'this also tests the modelItems function',
- },
- link_text: {
- en: 'click here',
- },
- link_url: {
- en: 'about:blank',
- },
- badge: {
- en: 'hero',
- },
- publish_on: new Date('2014-10-31T04:23:47Z'),
- expire_on: new Date('2049-10-31T04:23:47Z'),
- hash: 'defdefdef456456456',
- },
- ];
- expect(driver.modelItems(apiItems)).toMatchObject({
- error: null,
- feedItems: [
- {
- badge: null,
- description: 'this tests the modelItems function',
- hash: 'abcabc1231',
- linkText: 'click here',
- linkUrl: 'about:blank',
- title: 'guess what',
- },
- {
- badge: 'hero',
- description: 'this also tests the modelItems function',
- hash: 'defdefdef4',
- linkText: 'click here',
- linkUrl: 'about:blank',
- title: 'guess when',
- },
- ],
- hasNew: true,
- kibanaVersion: '99.999.9',
- });
- });
-
- it('Filters expired', () => {
- const driver = getDriver();
- const apiItems: ApiItem[] = [
- {
- title: {
- en: 'guess what',
- },
- description: {
- en: 'this tests the modelItems function',
- },
- link_text: {
- en: 'click here',
- },
- link_url: {
- en: 'about:blank',
- },
- publish_on: new Date('2013-10-31T04:23:47Z'),
- expire_on: new Date('2014-10-31T04:23:47Z'), // too old
- hash: 'abcabc1231123123hash',
- },
- ];
- expect(driver.modelItems(apiItems)).toMatchInlineSnapshot(`
- Object {
- "error": null,
- "feedItems": Array [],
- "hasNew": false,
- "kibanaVersion": "99.999.9",
- }
- `);
- });
+const createConfig = (mainInternal: number): NewsfeedPluginBrowserConfig => ({
+ mainInterval: moment.duration(mainInternal, 'ms'),
+ fetchInterval: moment.duration(mainInternal, 'ms'),
+ service: {
+ urlRoot: 'urlRoot',
+ pathTemplate: 'pathTemplate',
+ },
+});
- it('Filters pre-published', () => {
- const driver = getDriver();
- const apiItems: ApiItem[] = [
- {
- title: {
- en: 'guess what',
- },
- description: {
- en: 'this tests the modelItems function',
- },
- link_text: {
- en: 'click here',
- },
- link_url: {
- en: 'about:blank',
- },
- publish_on: new Date('2055-10-31T04:23:47Z'), // too new
- expire_on: new Date('2056-10-31T04:23:47Z'),
- hash: 'abcabc1231123123hash',
- },
- ];
- expect(driver.modelItems(apiItems)).toMatchInlineSnapshot(`
- Object {
- "error": null,
- "feedItems": Array [],
- "hasNew": false,
- "kibanaVersion": "99.999.9",
- }
- `);
- });
- });
+const createFetchResult = (parts: Partial): FetchResult => ({
+ feedItems: [],
+ hasNew: false,
+ error: null,
+ kibanaVersion,
+ ...parts,
});
describe('getApi', () => {
- const mockHttpGet = jest.fn();
- let httpMock = ({
- fetch: mockHttpGet,
- } as unknown) as HttpSetup;
- const getHttpMockWithItems = (mockApiItems: ApiItem[]) => (
- arg1: string,
- arg2: { method: string }
- ) => {
- if (
- arg1 === 'http://fakenews.co/kibana-test/v6.8.2.json' &&
- arg2.method &&
- arg2.method === 'GET'
- ) {
- return Promise.resolve({ items: mockApiItems });
- }
- return Promise.reject('wrong args!');
- };
- let configMock: NewsfeedPluginBrowserConfig;
-
- afterEach(() => {
- jest.resetAllMocks();
- });
-
beforeEach(() => {
- configMock = {
- service: {
- urlRoot: 'http://fakenews.co',
- pathTemplate: '/kibana-test/v{VERSION}.json',
- },
- mainInterval: moment.duration(86400000),
- fetchInterval: moment.duration(86400000),
- };
- httpMock = ({
- fetch: mockHttpGet,
- } as unknown) as HttpSetup;
+ driverInstanceMock.shouldFetch.mockReturnValue(true);
});
- it('creates a result', (done) => {
- mockHttpGet.mockImplementationOnce(() => Promise.resolve({ items: [] }));
- getApi(httpMock, configMock, '6.8.2').subscribe((result) => {
- expect(result).toMatchInlineSnapshot(`
- Object {
- "error": null,
- "feedItems": Array [],
- "hasNew": false,
- "kibanaVersion": "6.8.2",
- }
- `);
- done();
- });
+ afterEach(() => {
+ storageInstanceMock.isAnyUnread$.mockReset();
+ driverInstanceMock.fetchNewsfeedItems.mockReset();
});
- it('hasNew is true when the service returns hashes not in the cache', (done) => {
- const mockApiItems: ApiItem[] = [
- {
- title: {
- en: 'speaking English',
- es: 'habla Espanol',
- },
- description: {
- en: 'language test',
- es: 'idiomas',
- },
- languages: ['en', 'es'],
- link_text: {
- en: 'click here',
- es: 'aqui',
- },
- link_url: {
- en: 'xyzxyzxyz',
- es: 'abcabc',
- },
- badge: {
- en: 'firefighter',
- es: 'bombero',
- },
- publish_on: new Date('2014-10-31T04:23:47Z'),
- expire_on: new Date('2049-10-31T04:23:47Z'),
- hash: 'abcabc1231123123hash',
- },
- ];
-
- mockHttpGet.mockImplementationOnce(getHttpMockWithItems(mockApiItems));
-
- getApi(httpMock, configMock, '6.8.2').subscribe((result) => {
- expect(result).toMatchInlineSnapshot(`
- Object {
- "error": null,
- "feedItems": Array [
- Object {
- "badge": "firefighter",
- "description": "language test",
- "expireOn": "2049-10-31T04:23:47.000Z",
- "hash": "abcabc1231",
- "linkText": "click here",
- "linkUrl": "xyzxyzxyz",
- "publishOn": "2014-10-31T04:23:47.000Z",
- "title": "speaking English",
- },
- ],
- "hasNew": true,
- "kibanaVersion": "6.8.2",
- }
- `);
- done();
- });
- });
+ it('merges the newsfeed and unread observables', () => {
+ getTestScheduler().run(({ expectObservable, cold }) => {
+ storageInstanceMock.isAnyUnread$.mockImplementation(() => {
+ return cold('a|', {
+ a: true,
+ });
+ });
+ driverInstanceMock.fetchNewsfeedItems.mockReturnValue(
+ cold('a|', {
+ a: createFetchResult({ feedItems: ['item' as any] }),
+ })
+ );
+ const api = getApi(createConfig(1000), kibanaVersion, newsfeedId);
- it('hasNew is false when service returns hashes that are all stored', (done) => {
- localStorageGet.throws('Wrong key passed!');
- localStorageGet.withArgs(`${NEWSFEED_HASH_SET_STORAGE_KEY}.NEW_UUID`).returns('happyness');
- const mockApiItems: ApiItem[] = [
- {
- title: { en: 'hasNew test' },
- description: { en: 'test' },
- link_text: { en: 'click here' },
- link_url: { en: 'xyzxyzxyz' },
- badge: { en: 'firefighter' },
- publish_on: new Date('2014-10-31T04:23:47Z'),
- expire_on: new Date('2049-10-31T04:23:47Z'),
- hash: 'happyness',
- },
- ];
- mockHttpGet.mockImplementationOnce(getHttpMockWithItems(mockApiItems));
- getApi(httpMock, configMock, '6.8.2').subscribe((result) => {
- expect(result).toMatchInlineSnapshot(`
- Object {
- "error": null,
- "feedItems": Array [
- Object {
- "badge": "firefighter",
- "description": "test",
- "expireOn": "2049-10-31T04:23:47.000Z",
- "hash": "happyness",
- "linkText": "click here",
- "linkUrl": "xyzxyzxyz",
- "publishOn": "2014-10-31T04:23:47.000Z",
- "title": "hasNew test",
- },
- ],
- "hasNew": false,
- "kibanaVersion": "6.8.2",
- }
- `);
- done();
+ expectObservable(api.fetchResults$.pipe(take(1))).toBe('(a|)', {
+ a: createFetchResult({
+ feedItems: ['item' as any],
+ hasNew: true,
+ }),
+ });
});
});
- it('forwards an error', (done) => {
- mockHttpGet.mockImplementationOnce((arg1, arg2) => Promise.reject('sorry, try again later!'));
-
- getApi(httpMock, configMock, '6.8.2').subscribe((result) => {
- expect(result).toMatchInlineSnapshot(`
- Object {
- "error": "sorry, try again later!",
- "feedItems": Array [],
- "hasNew": false,
- "kibanaVersion": "6.8.2",
- }
- `);
- done();
+ it('emits based on the predefined interval', () => {
+ getTestScheduler().run(({ expectObservable, cold }) => {
+ storageInstanceMock.isAnyUnread$.mockImplementation(() => {
+ return cold('a|', {
+ a: true,
+ });
+ });
+ driverInstanceMock.fetchNewsfeedItems.mockReturnValue(
+ cold('a|', {
+ a: createFetchResult({ feedItems: ['item' as any] }),
+ })
+ );
+ const api = getApi(createConfig(2), kibanaVersion, newsfeedId);
+
+ expectObservable(api.fetchResults$.pipe(take(2))).toBe('a-(b|)', {
+ a: createFetchResult({
+ feedItems: ['item' as any],
+ hasNew: true,
+ }),
+ b: createFetchResult({
+ feedItems: ['item' as any],
+ hasNew: true,
+ }),
+ });
});
});
- describe('Retry fetching', () => {
- const successItems: ApiItem[] = [
- {
- title: { en: 'hasNew test' },
- description: { en: 'test' },
- link_text: { en: 'click here' },
- link_url: { en: 'xyzxyzxyz' },
- badge: { en: 'firefighter' },
- publish_on: new Date('2014-10-31T04:23:47Z'),
- expire_on: new Date('2049-10-31T04:23:47Z'),
- hash: 'happyness',
- },
- ];
-
- it("retries until fetch doesn't error", (done) => {
- configMock.mainInterval = moment.duration(10); // fast retry for testing
- mockHttpGet
- .mockImplementationOnce(() => Promise.reject('Sorry, try again later!'))
- .mockImplementationOnce(() => Promise.reject('Sorry, internal server error!'))
- .mockImplementationOnce(() => Promise.reject("Sorry, it's too cold to go outside!"))
- .mockImplementationOnce(getHttpMockWithItems(successItems));
-
- getApi(httpMock, configMock, '6.8.2')
- .pipe(take(4), toArray())
- .subscribe((result) => {
- expect(result).toMatchInlineSnapshot(`
- Array [
- Object {
- "error": "Sorry, try again later!",
- "feedItems": Array [],
- "hasNew": false,
- "kibanaVersion": "6.8.2",
- },
- Object {
- "error": "Sorry, internal server error!",
- "feedItems": Array [],
- "hasNew": false,
- "kibanaVersion": "6.8.2",
- },
- Object {
- "error": "Sorry, it's too cold to go outside!",
- "feedItems": Array [],
- "hasNew": false,
- "kibanaVersion": "6.8.2",
- },
- Object {
- "error": null,
- "feedItems": Array [
- Object {
- "badge": "firefighter",
- "description": "test",
- "expireOn": "2049-10-31T04:23:47.000Z",
- "hash": "happyness",
- "linkText": "click here",
- "linkUrl": "xyzxyzxyz",
- "publishOn": "2014-10-31T04:23:47.000Z",
- "title": "hasNew test",
- },
- ],
- "hasNew": false,
- "kibanaVersion": "6.8.2",
- },
- ]
- `);
- done();
+ it('re-emits when the unread status changes', () => {
+ getTestScheduler().run(({ expectObservable, cold }) => {
+ storageInstanceMock.isAnyUnread$.mockImplementation(() => {
+ return cold('a--b', {
+ a: true,
+ b: false,
});
- });
-
- it("doesn't retry if fetch succeeds", (done) => {
- configMock.mainInterval = moment.duration(10); // fast retry for testing
- mockHttpGet.mockImplementation(getHttpMockWithItems(successItems));
-
- const timeout$ = interval(1000); // lets us capture some results after a short time
- let timesFetched = 0;
-
- const get$ = getApi(httpMock, configMock, '6.8.2').pipe(
- tap(() => {
- timesFetched++;
+ });
+ driverInstanceMock.fetchNewsfeedItems.mockReturnValue(
+ cold('(a|)', {
+ a: createFetchResult({}),
})
);
-
- race(get$, timeout$).subscribe(() => {
- expect(timesFetched).toBe(1); // first fetch was successful, so there was no retry
- done();
+ const api = getApi(createConfig(10), kibanaVersion, newsfeedId);
+
+ expectObservable(api.fetchResults$.pipe(take(2))).toBe('a--(b|)', {
+ a: createFetchResult({
+ hasNew: true,
+ }),
+ b: createFetchResult({
+ hasNew: false,
+ }),
});
});
});
diff --git a/src/plugins/newsfeed/public/lib/api.ts b/src/plugins/newsfeed/public/lib/api.ts
index 9b1274a25d4861..4fbbd8687b73fc 100644
--- a/src/plugins/newsfeed/public/lib/api.ts
+++ b/src/plugins/newsfeed/public/lib/api.ts
@@ -6,21 +6,12 @@
* Side Public License, v 1.
*/
-import * as Rx from 'rxjs';
-import moment from 'moment';
-import uuid from 'uuid';
+import { combineLatest, Observable, timer, of } from 'rxjs';
+import { map, catchError, filter, mergeMap, tap } from 'rxjs/operators';
import { i18n } from '@kbn/i18n';
-import { catchError, filter, mergeMap, tap } from 'rxjs/operators';
-import { HttpSetup } from 'src/core/public';
-import {
- NEWSFEED_DEFAULT_SERVICE_BASE_URL,
- NEWSFEED_FALLBACK_LANGUAGE,
- NEWSFEED_LAST_FETCH_STORAGE_KEY,
- NEWSFEED_HASH_SET_STORAGE_KEY,
-} from '../../common/constants';
-import { ApiItem, NewsfeedItem, FetchResult, NewsfeedPluginBrowserConfig } from '../types';
-
-type ApiConfig = NewsfeedPluginBrowserConfig['service'];
+import { FetchResult, NewsfeedPluginBrowserConfig } from '../types';
+import { NewsfeedApiDriver } from './driver';
+import { NewsfeedStorage } from './storage';
export enum NewsfeedApiEndpoint {
KIBANA = 'kibana',
@@ -29,145 +20,17 @@ export enum NewsfeedApiEndpoint {
OBSERVABILITY = 'observability',
}
-export class NewsfeedApiDriver {
- private readonly id = uuid.v4();
- private readonly kibanaVersion: string;
- private readonly loadedTime = moment().utc(); // the date is compared to time in UTC format coming from the service
- private readonly lastFetchStorageKey: string;
- private readonly hashSetStorageKey: string;
-
- constructor(
- kibanaVersion: string,
- private readonly userLanguage: string,
- private readonly fetchInterval: number
- ) {
- // The API only accepts versions in the format `X.Y.Z`, so we need to drop the `-SNAPSHOT` or any other label after it
- this.kibanaVersion = kibanaVersion.replace(/^(\d+\.\d+\.\d+).*/, '$1');
- this.lastFetchStorageKey = `${NEWSFEED_LAST_FETCH_STORAGE_KEY}.${this.id}`;
- this.hashSetStorageKey = `${NEWSFEED_HASH_SET_STORAGE_KEY}.${this.id}`;
- }
-
- shouldFetch(): boolean {
- const lastFetchUtc: string | null = sessionStorage.getItem(this.lastFetchStorageKey);
- if (lastFetchUtc == null) {
- return true;
- }
- const last = moment(lastFetchUtc, 'x'); // parse as unix ms timestamp (already is UTC)
-
- // does the last fetch time precede the time that the page was loaded?
- if (this.loadedTime.diff(last) > 0) {
- return true;
- }
-
- const now = moment.utc(); // always use UTC to compare timestamps that came from the service
- const duration = moment.duration(now.diff(last));
-
- return duration.asMilliseconds() > this.fetchInterval;
- }
-
- updateLastFetch() {
- sessionStorage.setItem(this.lastFetchStorageKey, Date.now().toString());
- }
-
- updateHashes(items: NewsfeedItem[]): { previous: string[]; current: string[] } {
- // replace localStorage hashes with new hashes
- const stored: string | null = localStorage.getItem(this.hashSetStorageKey);
- let old: string[] = [];
- if (stored != null) {
- old = stored.split(',');
- }
-
- const newHashes = items.map((i) => i.hash);
- const updatedHashes = [...new Set(old.concat(newHashes))];
- localStorage.setItem(this.hashSetStorageKey, updatedHashes.join(','));
-
- return { previous: old, current: updatedHashes };
- }
-
- fetchNewsfeedItems(http: HttpSetup, config: ApiConfig): Rx.Observable {
- const urlPath = config.pathTemplate.replace('{VERSION}', this.kibanaVersion);
- const fullUrl = (config.urlRoot || NEWSFEED_DEFAULT_SERVICE_BASE_URL) + urlPath;
-
- return Rx.from(
- http
- .fetch(fullUrl, {
- method: 'GET',
- })
- .then(({ items }: { items: ApiItem[] }) => {
- return this.modelItems(items);
- })
- );
- }
-
- validateItem(item: Partial) {
- const hasMissing = [
- item.title,
- item.description,
- item.linkText,
- item.linkUrl,
- item.publishOn,
- item.hash,
- ].includes(undefined);
-
- return !hasMissing;
- }
-
- modelItems(items: ApiItem[]): FetchResult {
- const feedItems: NewsfeedItem[] = items.reduce((accum: NewsfeedItem[], it: ApiItem) => {
- let chosenLanguage = this.userLanguage;
- const {
- expire_on: expireOnUtc,
- publish_on: publishOnUtc,
- languages,
- title,
- description,
- link_text: linkText,
- link_url: linkUrl,
- badge,
- hash,
- } = it;
-
- if (moment(expireOnUtc).isBefore(Date.now())) {
- return accum; // ignore item if expired
- }
-
- if (moment(publishOnUtc).isAfter(Date.now())) {
- return accum; // ignore item if publish date hasn't occurred yet (pre-published)
- }
-
- if (languages && !languages.includes(chosenLanguage)) {
- chosenLanguage = NEWSFEED_FALLBACK_LANGUAGE; // don't remove the item: fallback on a language
- }
-
- const tempItem: NewsfeedItem = {
- title: title[chosenLanguage],
- description: description[chosenLanguage],
- linkText: linkText != null ? linkText[chosenLanguage] : null,
- linkUrl: linkUrl[chosenLanguage],
- badge: badge != null ? badge![chosenLanguage] : null,
- publishOn: moment(publishOnUtc),
- expireOn: moment(expireOnUtc),
- hash: hash.slice(0, 10), // optimize for storage and faster parsing
- };
-
- if (!this.validateItem(tempItem)) {
- return accum; // ignore if title, description, etc is missing
- }
-
- return [...accum, tempItem];
- }, []);
-
- // calculate hasNew
- const { previous, current } = this.updateHashes(feedItems);
- const hasNew = current.length > previous.length;
-
- return {
- error: null,
- kibanaVersion: this.kibanaVersion,
- hasNew,
- feedItems,
- };
- }
+export interface NewsfeedApi {
+ /**
+ * The current fetch results
+ */
+ fetchResults$: Observable;
+
+ /**
+ * Mark the given items as read.
+ * Will refresh the `hasNew` value of the emitted FetchResult accordingly
+ */
+ markAsRead(itemHashes: string[]): void;
}
/*
@@ -175,22 +38,23 @@ export class NewsfeedApiDriver {
* Computes hasNew value from new item hashes saved in localStorage
*/
export function getApi(
- http: HttpSetup,
config: NewsfeedPluginBrowserConfig,
- kibanaVersion: string
-): Rx.Observable {
+ kibanaVersion: string,
+ newsfeedId: string
+): NewsfeedApi {
const userLanguage = i18n.getLocale();
const fetchInterval = config.fetchInterval.asMilliseconds();
const mainInterval = config.mainInterval.asMilliseconds();
- const driver = new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval);
+ const storage = new NewsfeedStorage(newsfeedId);
+ const driver = new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval, storage);
- return Rx.timer(0, mainInterval).pipe(
+ const results$ = timer(0, mainInterval).pipe(
filter(() => driver.shouldFetch()),
mergeMap(() =>
- driver.fetchNewsfeedItems(http, config.service).pipe(
+ driver.fetchNewsfeedItems(config.service).pipe(
catchError((err) => {
window.console.error(err);
- return Rx.of({
+ return of({
error: err,
kibanaVersion,
hasNew: false,
@@ -199,6 +63,22 @@ export function getApi(
})
)
),
- tap(() => driver.updateLastFetch())
+ tap(() => storage.setLastFetchTime(new Date()))
);
+
+ const merged$ = combineLatest([results$, storage.isAnyUnread$()]).pipe(
+ map(([results, isAnyUnread]) => {
+ return {
+ ...results,
+ hasNew: results.error ? false : isAnyUnread,
+ };
+ })
+ );
+
+ return {
+ fetchResults$: merged$,
+ markAsRead: (itemHashes) => {
+ storage.markItemsAsRead(itemHashes);
+ },
+ };
}
diff --git a/src/plugins/newsfeed/public/lib/convert_items.test.ts b/src/plugins/newsfeed/public/lib/convert_items.test.ts
new file mode 100644
index 00000000000000..8b599d841935c0
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/convert_items.test.ts
@@ -0,0 +1,144 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import moment from 'moment';
+import { omit } from 'lodash';
+import { validateIntegrity, validatePublishedDate, localizeItem } from './convert_items';
+import type { ApiItem, NewsfeedItem } from '../types';
+
+const createApiItem = (parts: Partial = {}): ApiItem => ({
+ hash: 'hash',
+ expire_on: new Date(),
+ publish_on: new Date(),
+ title: {},
+ description: {},
+ link_url: {},
+ ...parts,
+});
+
+const createNewsfeedItem = (parts: Partial = {}): NewsfeedItem => ({
+ title: 'title',
+ description: 'description',
+ linkText: 'linkText',
+ linkUrl: 'linkUrl',
+ badge: 'badge',
+ publishOn: moment(),
+ expireOn: moment(),
+ hash: 'hash',
+ ...parts,
+});
+
+describe('localizeItem', () => {
+ const item = createApiItem({
+ languages: ['en', 'fr'],
+ title: {
+ en: 'en title',
+ fr: 'fr title',
+ },
+ description: {
+ en: 'en desc',
+ fr: 'fr desc',
+ },
+ link_text: {
+ en: 'en link text',
+ fr: 'fr link text',
+ },
+ link_url: {
+ en: 'en link url',
+ fr: 'fr link url',
+ },
+ badge: {
+ en: 'en badge',
+ fr: 'fr badge',
+ },
+ publish_on: new Date('2014-10-31T04:23:47Z'),
+ expire_on: new Date('2049-10-31T04:23:47Z'),
+ hash: 'hash',
+ });
+
+ it('converts api items to newsfeed items using the specified language', () => {
+ expect(localizeItem(item, 'fr')).toMatchObject({
+ title: 'fr title',
+ description: 'fr desc',
+ linkText: 'fr link text',
+ linkUrl: 'fr link url',
+ badge: 'fr badge',
+ hash: 'hash',
+ });
+ });
+
+ it('fallbacks to `en` is the language is not present', () => {
+ expect(localizeItem(item, 'de')).toMatchObject({
+ title: 'en title',
+ description: 'en desc',
+ linkText: 'en link text',
+ linkUrl: 'en link url',
+ badge: 'en badge',
+ hash: 'hash',
+ });
+ });
+});
+
+describe('validatePublishedDate', () => {
+ it('returns false when the publish date is not reached yet', () => {
+ expect(
+ validatePublishedDate(
+ createApiItem({
+ publish_on: new Date('2055-10-31T04:23:47Z'), // too new
+ expire_on: new Date('2056-10-31T04:23:47Z'),
+ })
+ )
+ ).toBe(false);
+ });
+
+ it('returns false when the expire date is already reached', () => {
+ expect(
+ validatePublishedDate(
+ createApiItem({
+ publish_on: new Date('2013-10-31T04:23:47Z'),
+ expire_on: new Date('2014-10-31T04:23:47Z'), // too old
+ })
+ )
+ ).toBe(false);
+ });
+
+ it('returns true when current date is between the publish and expire dates', () => {
+ expect(
+ validatePublishedDate(
+ createApiItem({
+ publish_on: new Date('2014-10-31T04:23:47Z'),
+ expire_on: new Date('2049-10-31T04:23:47Z'),
+ })
+ )
+ ).toBe(true);
+ });
+});
+
+describe('validateIntegrity', () => {
+ it('returns false if `title` is missing', () => {
+ expect(validateIntegrity(omit(createNewsfeedItem(), 'title'))).toBe(false);
+ });
+ it('returns false if `description` is missing', () => {
+ expect(validateIntegrity(omit(createNewsfeedItem(), 'description'))).toBe(false);
+ });
+ it('returns false if `linkText` is missing', () => {
+ expect(validateIntegrity(omit(createNewsfeedItem(), 'linkText'))).toBe(false);
+ });
+ it('returns false if `linkUrl` is missing', () => {
+ expect(validateIntegrity(omit(createNewsfeedItem(), 'linkUrl'))).toBe(false);
+ });
+ it('returns false if `publishOn` is missing', () => {
+ expect(validateIntegrity(omit(createNewsfeedItem(), 'publishOn'))).toBe(false);
+ });
+ it('returns false if `hash` is missing', () => {
+ expect(validateIntegrity(omit(createNewsfeedItem(), 'hash'))).toBe(false);
+ });
+ it('returns true if all mandatory fields are present', () => {
+ expect(validateIntegrity(createNewsfeedItem())).toBe(true);
+ });
+});
diff --git a/src/plugins/newsfeed/public/lib/convert_items.ts b/src/plugins/newsfeed/public/lib/convert_items.ts
new file mode 100644
index 00000000000000..38ea2cc895f3e8
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/convert_items.ts
@@ -0,0 +1,72 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import moment from 'moment';
+import { ApiItem, NewsfeedItem } from '../types';
+import { NEWSFEED_FALLBACK_LANGUAGE } from '../../common/constants';
+
+export const convertItems = (items: ApiItem[], userLanguage: string): NewsfeedItem[] => {
+ return items
+ .filter(validatePublishedDate)
+ .map((item) => localizeItem(item, userLanguage))
+ .filter(validateIntegrity);
+};
+
+export const validatePublishedDate = (item: ApiItem): boolean => {
+ if (moment(item.expire_on).isBefore(Date.now())) {
+ return false; // ignore item if expired
+ }
+
+ if (moment(item.publish_on).isAfter(Date.now())) {
+ return false; // ignore item if publish date hasn't occurred yet (pre-published)
+ }
+ return true;
+};
+
+export const localizeItem = (rawItem: ApiItem, userLanguage: string): NewsfeedItem => {
+ const {
+ expire_on: expireOnUtc,
+ publish_on: publishOnUtc,
+ languages,
+ title,
+ description,
+ link_text: linkText,
+ link_url: linkUrl,
+ badge,
+ hash,
+ } = rawItem;
+
+ let chosenLanguage = userLanguage;
+ if (languages && !languages.includes(chosenLanguage)) {
+ chosenLanguage = NEWSFEED_FALLBACK_LANGUAGE; // don't remove the item: fallback on a language
+ }
+
+ return {
+ title: title[chosenLanguage],
+ description: description[chosenLanguage],
+ linkText: linkText != null ? linkText[chosenLanguage] : null,
+ linkUrl: linkUrl[chosenLanguage],
+ badge: badge != null ? badge![chosenLanguage] : null,
+ publishOn: moment(publishOnUtc),
+ expireOn: moment(expireOnUtc),
+ hash: hash.slice(0, 10), // optimize for storage and faster parsing
+ };
+};
+
+export const validateIntegrity = (item: Partial): boolean => {
+ const hasMissing = [
+ item.title,
+ item.description,
+ item.linkText,
+ item.linkUrl,
+ item.publishOn,
+ item.hash,
+ ].includes(undefined);
+
+ return !hasMissing;
+};
diff --git a/src/plugins/newsfeed/public/lib/driver.mock.ts b/src/plugins/newsfeed/public/lib/driver.mock.ts
new file mode 100644
index 00000000000000..8ae4ad1a82c4d6
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/driver.mock.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { PublicMethodsOf } from '@kbn/utility-types';
+import type { NewsfeedApiDriver } from './driver';
+
+const createDriverMock = () => {
+ const mock: jest.Mocked> = {
+ shouldFetch: jest.fn(),
+ fetchNewsfeedItems: jest.fn(),
+ };
+ return mock as jest.Mocked;
+};
+
+export const driverMock = {
+ create: createDriverMock,
+};
diff --git a/src/plugins/newsfeed/public/lib/driver.test.mocks.ts b/src/plugins/newsfeed/public/lib/driver.test.mocks.ts
new file mode 100644
index 00000000000000..2d7123ebc2d1f2
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/driver.test.mocks.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export const convertItemsMock = jest.fn();
+jest.doMock('./convert_items', () => ({
+ convertItems: convertItemsMock,
+}));
diff --git a/src/plugins/newsfeed/public/lib/driver.test.ts b/src/plugins/newsfeed/public/lib/driver.test.ts
new file mode 100644
index 00000000000000..38ec90cf201019
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/driver.test.ts
@@ -0,0 +1,133 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { convertItemsMock } from './driver.test.mocks';
+// @ts-expect-error
+import fetchMock from 'fetch-mock/es5/client';
+import { take } from 'rxjs/operators';
+import { NewsfeedApiDriver } from './driver';
+import { storageMock } from './storage.mock';
+
+const kibanaVersion = '8.0.0';
+const userLanguage = 'en';
+const fetchInterval = 2000;
+
+describe('NewsfeedApiDriver', () => {
+ let driver: NewsfeedApiDriver;
+ let storage: ReturnType;
+
+ beforeEach(() => {
+ storage = storageMock.create();
+ driver = new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval, storage);
+ convertItemsMock.mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ fetchMock.reset();
+ convertItemsMock.mockReset();
+ });
+
+ afterAll(() => {
+ fetchMock.restore();
+ });
+
+ describe('shouldFetch', () => {
+ it('returns true if no value is present in the storage', () => {
+ storage.getLastFetchTime.mockReturnValue(undefined);
+ expect(driver.shouldFetch()).toBe(true);
+ expect(storage.getLastFetchTime).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns true if last fetch time precedes page load time', () => {
+ storage.getLastFetchTime.mockReturnValue(new Date(Date.now() - 456789));
+ expect(driver.shouldFetch()).toBe(true);
+ });
+
+ it('returns false if last fetch time is recent enough', () => {
+ storage.getLastFetchTime.mockReturnValue(new Date(Date.now() + 745678));
+ expect(driver.shouldFetch()).toBe(false);
+ });
+ });
+
+ describe('fetchNewsfeedItems', () => {
+ it('calls `window.fetch` with the correct parameters', async () => {
+ fetchMock.get('*', { items: [] });
+ await driver
+ .fetchNewsfeedItems({
+ urlRoot: 'http://newsfeed.com',
+ pathTemplate: '/{VERSION}/news',
+ })
+ .pipe(take(1))
+ .toPromise();
+
+ expect(fetchMock.lastUrl()).toEqual('http://newsfeed.com/8.0.0/news');
+ expect(fetchMock.lastOptions()).toEqual({
+ method: 'GET',
+ });
+ });
+
+ it('calls `convertItems` with the correct parameters', async () => {
+ fetchMock.get('*', { items: ['foo', 'bar'] });
+
+ await driver
+ .fetchNewsfeedItems({
+ urlRoot: 'http://newsfeed.com',
+ pathTemplate: '/{VERSION}/news',
+ })
+ .pipe(take(1))
+ .toPromise();
+
+ expect(convertItemsMock).toHaveBeenCalledTimes(1);
+ expect(convertItemsMock).toHaveBeenCalledWith(['foo', 'bar'], userLanguage);
+ });
+
+ it('calls `storage.setFetchedItems` with the correct parameters', async () => {
+ fetchMock.get('*', { items: [] });
+ convertItemsMock.mockReturnValue([
+ { id: '1', hash: 'hash1' },
+ { id: '2', hash: 'hash2' },
+ ]);
+
+ await driver
+ .fetchNewsfeedItems({
+ urlRoot: 'http://newsfeed.com',
+ pathTemplate: '/{VERSION}/news',
+ })
+ .pipe(take(1))
+ .toPromise();
+
+ expect(storage.setFetchedItems).toHaveBeenCalledTimes(1);
+ expect(storage.setFetchedItems).toHaveBeenCalledWith(['hash1', 'hash2']);
+ });
+
+ it('returns the expected values', async () => {
+ fetchMock.get('*', { items: [] });
+ const feedItems = [
+ { id: '1', hash: 'hash1' },
+ { id: '2', hash: 'hash2' },
+ ];
+ convertItemsMock.mockReturnValue(feedItems);
+ storage.setFetchedItems.mockReturnValue(true);
+
+ const result = await driver
+ .fetchNewsfeedItems({
+ urlRoot: 'http://newsfeed.com',
+ pathTemplate: '/{VERSION}/news',
+ })
+ .pipe(take(1))
+ .toPromise();
+
+ expect(result).toEqual({
+ error: null,
+ kibanaVersion,
+ hasNew: true,
+ feedItems,
+ });
+ });
+ });
+});
diff --git a/src/plugins/newsfeed/public/lib/driver.ts b/src/plugins/newsfeed/public/lib/driver.ts
new file mode 100644
index 00000000000000..0efa981e8c89d6
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/driver.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
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import moment from 'moment';
+import * as Rx from 'rxjs';
+import { NEWSFEED_DEFAULT_SERVICE_BASE_URL } from '../../common/constants';
+import { ApiItem, FetchResult, NewsfeedPluginBrowserConfig } from '../types';
+import { convertItems } from './convert_items';
+import type { NewsfeedStorage } from './storage';
+
+type ApiConfig = NewsfeedPluginBrowserConfig['service'];
+
+interface NewsfeedResponse {
+ items: ApiItem[];
+}
+
+export class NewsfeedApiDriver {
+ private readonly kibanaVersion: string;
+ private readonly loadedTime = moment().utc(); // the date is compared to time in UTC format coming from the service
+
+ constructor(
+ kibanaVersion: string,
+ private readonly userLanguage: string,
+ private readonly fetchInterval: number,
+ private readonly storage: NewsfeedStorage
+ ) {
+ // The API only accepts versions in the format `X.Y.Z`, so we need to drop the `-SNAPSHOT` or any other label after it
+ this.kibanaVersion = kibanaVersion.replace(/^(\d+\.\d+\.\d+).*/, '$1');
+ }
+
+ shouldFetch(): boolean {
+ const lastFetchUtc = this.storage.getLastFetchTime();
+ if (!lastFetchUtc) {
+ return true;
+ }
+ const last = moment(lastFetchUtc);
+
+ // does the last fetch time precede the time that the page was loaded?
+ if (this.loadedTime.diff(last) > 0) {
+ return true;
+ }
+
+ const now = moment.utc(); // always use UTC to compare timestamps that came from the service
+ const duration = moment.duration(now.diff(last));
+ return duration.asMilliseconds() > this.fetchInterval;
+ }
+
+ fetchNewsfeedItems(config: ApiConfig): Rx.Observable {
+ const urlPath = config.pathTemplate.replace('{VERSION}', this.kibanaVersion);
+ const fullUrl = (config.urlRoot || NEWSFEED_DEFAULT_SERVICE_BASE_URL) + urlPath;
+ const request = new Request(fullUrl, {
+ method: 'GET',
+ });
+
+ return Rx.from(
+ window.fetch(request).then(async (response) => {
+ const { items } = (await response.json()) as NewsfeedResponse;
+ return this.convertResponse(items);
+ })
+ );
+ }
+
+ private convertResponse(items: ApiItem[]): FetchResult {
+ const feedItems = convertItems(items, this.userLanguage);
+ const hasNew = this.storage.setFetchedItems(feedItems.map((item) => item.hash));
+
+ return {
+ error: null,
+ kibanaVersion: this.kibanaVersion,
+ hasNew,
+ feedItems,
+ };
+ }
+}
diff --git a/src/plugins/newsfeed/public/lib/storage.mock.ts b/src/plugins/newsfeed/public/lib/storage.mock.ts
new file mode 100644
index 00000000000000..98681e20b06658
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/storage.mock.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { PublicMethodsOf } from '@kbn/utility-types';
+import type { NewsfeedStorage } from './storage';
+
+const createStorageMock = () => {
+ const mock: jest.Mocked> = {
+ getLastFetchTime: jest.fn(),
+ setLastFetchTime: jest.fn(),
+ setFetchedItems: jest.fn(),
+ markItemsAsRead: jest.fn(),
+ isAnyUnread: jest.fn(),
+ isAnyUnread$: jest.fn(),
+ };
+ return mock as jest.Mocked;
+};
+
+export const storageMock = {
+ create: createStorageMock,
+};
diff --git a/src/plugins/newsfeed/public/lib/storage.test.ts b/src/plugins/newsfeed/public/lib/storage.test.ts
new file mode 100644
index 00000000000000..1c424d8247e863
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/storage.test.ts
@@ -0,0 +1,165 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { NewsfeedStorage, getStorageKey } from './storage';
+import { take } from 'rxjs/operators';
+
+describe('NewsfeedStorage', () => {
+ const storagePrefix = 'test';
+ let mockStorage: Record;
+ let storage: NewsfeedStorage;
+
+ const getKey = (key: string) => getStorageKey(storagePrefix, key);
+
+ beforeAll(() => {
+ Object.defineProperty(window, 'localStorage', {
+ value: {
+ getItem: (key: string) => {
+ return mockStorage[key] || null;
+ },
+ setItem: (key: string, value: string) => {
+ mockStorage[key] = value;
+ },
+ },
+ writable: true,
+ });
+ });
+
+ afterAll(() => {
+ delete (window as any).localStorage;
+ });
+
+ beforeEach(() => {
+ mockStorage = {};
+ storage = new NewsfeedStorage(storagePrefix);
+ });
+
+ describe('getLastFetchTime', () => {
+ it('returns undefined if not set', () => {
+ expect(storage.getLastFetchTime()).toBeUndefined();
+ });
+
+ it('returns the last value that was set', () => {
+ const date = new Date();
+ storage.setLastFetchTime(date);
+ expect(storage.getLastFetchTime()!.getTime()).toEqual(date.getTime());
+ });
+ });
+
+ describe('setFetchedItems', () => {
+ it('updates the value in the storage', () => {
+ storage.setFetchedItems(['a', 'b', 'c']);
+ expect(JSON.parse(localStorage.getItem(getKey('readStatus'))!)).toEqual({
+ a: false,
+ b: false,
+ c: false,
+ });
+ });
+
+ it('preserves the read status if present', () => {
+ const initialValue = { a: true, b: false };
+ window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue));
+ storage.setFetchedItems(['a', 'b', 'c']);
+ expect(JSON.parse(localStorage.getItem(getKey('readStatus'))!)).toEqual({
+ a: true,
+ b: false,
+ c: false,
+ });
+ });
+
+ it('removes the old keys from the storage', () => {
+ const initialValue = { a: true, b: false, old: false };
+ window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue));
+ storage.setFetchedItems(['a', 'b', 'c']);
+ expect(JSON.parse(localStorage.getItem(getKey('readStatus'))!)).toEqual({
+ a: true,
+ b: false,
+ c: false,
+ });
+ });
+ });
+
+ describe('markItemsAsRead', () => {
+ it('flags the entries as read', () => {
+ const initialValue = { a: true, b: false, c: false };
+ window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue));
+ storage.markItemsAsRead(['b']);
+ expect(JSON.parse(localStorage.getItem(getKey('readStatus'))!)).toEqual({
+ a: true,
+ b: true,
+ c: false,
+ });
+ });
+
+ it('add the entries when not present', () => {
+ const initialValue = { a: true, b: false, c: false };
+ window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue));
+ storage.markItemsAsRead(['b', 'new']);
+ expect(JSON.parse(localStorage.getItem(getKey('readStatus'))!)).toEqual({
+ a: true,
+ b: true,
+ c: false,
+ new: true,
+ });
+ });
+ });
+
+ describe('isAnyUnread', () => {
+ it('returns true if any item was not read', () => {
+ storage.setFetchedItems(['a', 'b', 'c']);
+ storage.markItemsAsRead(['a']);
+ expect(storage.isAnyUnread()).toBe(true);
+ });
+
+ it('returns true if all item are unread', () => {
+ storage.setFetchedItems(['a', 'b', 'c']);
+ expect(storage.isAnyUnread()).toBe(true);
+ });
+
+ it('returns false if all item are unread', () => {
+ storage.setFetchedItems(['a', 'b', 'c']);
+ storage.markItemsAsRead(['a', 'b', 'c']);
+ expect(storage.isAnyUnread()).toBe(false);
+ });
+
+ it('loads the value initially present in localStorage', () => {
+ const initialValue = { a: true, b: false };
+ window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue));
+ storage = new NewsfeedStorage(storagePrefix);
+ expect(storage.isAnyUnread()).toBe(true);
+ });
+ });
+
+ describe('isAnyUnread$', () => {
+ it('emits an initial value at subscription', async () => {
+ const initialValue = { a: true, b: false, c: false };
+ window.localStorage.setItem(getKey('readStatus'), JSON.stringify(initialValue));
+ storage = new NewsfeedStorage(storagePrefix);
+
+ expect(await storage.isAnyUnread$().pipe(take(1)).toPromise()).toBe(true);
+ });
+
+ it('emits when `setFetchedItems` is called', () => {
+ const emissions: boolean[] = [];
+ storage.isAnyUnread$().subscribe((unread) => emissions.push(unread));
+
+ storage.setFetchedItems(['a', 'b', 'c']);
+ expect(emissions).toEqual([false, true]);
+ });
+
+ it('emits when `markItemsAsRead` is called', () => {
+ const emissions: boolean[] = [];
+ storage.isAnyUnread$().subscribe((unread) => emissions.push(unread));
+
+ storage.setFetchedItems(['a', 'b', 'c']);
+ storage.markItemsAsRead(['a', 'b']);
+ storage.markItemsAsRead(['c']);
+ expect(emissions).toEqual([false, true, true, false]);
+ });
+ });
+});
diff --git a/src/plugins/newsfeed/public/lib/storage.ts b/src/plugins/newsfeed/public/lib/storage.ts
new file mode 100644
index 00000000000000..f3df242ad94236
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/storage.ts
@@ -0,0 +1,89 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import moment from 'moment';
+import { Observable, BehaviorSubject } from 'rxjs';
+
+/**
+ * Persistence layer for the newsfeed driver
+ */
+export class NewsfeedStorage {
+ private readonly lastFetchStorageKey: string;
+ private readonly readStatusStorageKey: string;
+ private readonly unreadStatus$: BehaviorSubject;
+
+ constructor(storagePrefix: string) {
+ this.lastFetchStorageKey = getStorageKey(storagePrefix, 'lastFetch');
+ this.readStatusStorageKey = getStorageKey(storagePrefix, 'readStatus');
+ this.unreadStatus$ = new BehaviorSubject(anyUnread(this.getReadStatus()));
+ }
+
+ getLastFetchTime(): Date | undefined {
+ const lastFetchUtc = localStorage.getItem(this.lastFetchStorageKey);
+ if (!lastFetchUtc) {
+ return undefined;
+ }
+
+ return moment(lastFetchUtc, 'x').toDate(); // parse as unix ms timestamp (already is UTC)
+ }
+
+ setLastFetchTime(date: Date) {
+ localStorage.setItem(this.lastFetchStorageKey, JSON.stringify(date.getTime()));
+ }
+
+ setFetchedItems(itemHashes: string[]): boolean {
+ const currentReadStatus = this.getReadStatus();
+
+ const newReadStatus: Record = {};
+ itemHashes.forEach((hash) => {
+ newReadStatus[hash] = currentReadStatus[hash] ?? false;
+ });
+
+ return this.setReadStatus(newReadStatus);
+ }
+
+ /**
+ * Marks given items as read, and return the overall unread status.
+ */
+ markItemsAsRead(itemHashes: string[]): boolean {
+ const updatedReadStatus = this.getReadStatus();
+ itemHashes.forEach((hash) => {
+ updatedReadStatus[hash] = true;
+ });
+ return this.setReadStatus(updatedReadStatus);
+ }
+
+ isAnyUnread(): boolean {
+ return this.unreadStatus$.value;
+ }
+
+ isAnyUnread$(): Observable {
+ return this.unreadStatus$.asObservable();
+ }
+
+ private getReadStatus(): Record {
+ try {
+ return JSON.parse(localStorage.getItem(this.readStatusStorageKey) || '{}');
+ } catch (e) {
+ return {};
+ }
+ }
+
+ private setReadStatus(status: Record) {
+ const hasUnread = anyUnread(status);
+ this.unreadStatus$.next(anyUnread(status));
+ localStorage.setItem(this.readStatusStorageKey, JSON.stringify(status));
+ return hasUnread;
+ }
+}
+
+const anyUnread = (status: Record): boolean =>
+ Object.values(status).some((read) => !read);
+
+/** @internal */
+export const getStorageKey = (prefix: string, key: string) => `newsfeed.${prefix}.${key}`;
diff --git a/src/plugins/newsfeed/public/plugin.tsx b/src/plugins/newsfeed/public/plugin.tsx
index a788b3c4d0b59f..fdda0a24b8bd56 100644
--- a/src/plugins/newsfeed/public/plugin.tsx
+++ b/src/plugins/newsfeed/public/plugin.tsx
@@ -7,15 +7,15 @@
*/
import * as Rx from 'rxjs';
-import { catchError, takeUntil, share } from 'rxjs/operators';
+import { catchError, takeUntil } from 'rxjs/operators';
import ReactDOM from 'react-dom';
import React from 'react';
import moment from 'moment';
import { I18nProvider } from '@kbn/i18n/react';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public';
-import { NewsfeedPluginBrowserConfig, FetchResult } from './types';
-import { NewsfeedNavButton, NewsfeedApiFetchResult } from './components/newsfeed_header_nav_button';
-import { getApi, NewsfeedApiEndpoint } from './lib/api';
+import { NewsfeedPluginBrowserConfig } from './types';
+import { NewsfeedNavButton } from './components/newsfeed_header_nav_button';
+import { getApi, NewsfeedApi, NewsfeedApiEndpoint } from './lib/api';
export type NewsfeedPublicPluginSetup = ReturnType;
export type NewsfeedPublicPluginStart = ReturnType;
@@ -42,10 +42,10 @@ export class NewsfeedPublicPlugin
}
public start(core: CoreStart) {
- const api$ = this.fetchNewsfeed(core, this.config).pipe(share());
+ const api = this.createNewsfeedApi(this.config, NewsfeedApiEndpoint.KIBANA);
core.chrome.navControls.registerRight({
order: 1000,
- mount: (target) => this.mount(api$, target),
+ mount: (target) => this.mount(api, target),
});
return {
@@ -56,7 +56,8 @@ export class NewsfeedPublicPlugin
pathTemplate: `/${endpoint}/v{VERSION}.json`,
},
});
- return this.fetchNewsfeed(core, config);
+ const { fetchResults$ } = this.createNewsfeedApi(config, endpoint);
+ return fetchResults$;
},
};
}
@@ -65,21 +66,24 @@ export class NewsfeedPublicPlugin
this.stop$.next();
}
- private fetchNewsfeed(
- core: CoreStart,
- config: NewsfeedPluginBrowserConfig
- ): Rx.Observable {
- const { http } = core;
- return getApi(http, config, this.kibanaVersion).pipe(
- takeUntil(this.stop$), // stop the interval when stop method is called
- catchError(() => Rx.of(null)) // do not throw error
- );
+ private createNewsfeedApi(
+ config: NewsfeedPluginBrowserConfig,
+ newsfeedId: NewsfeedApiEndpoint
+ ): NewsfeedApi {
+ const api = getApi(config, this.kibanaVersion, newsfeedId);
+ return {
+ markAsRead: api.markAsRead,
+ fetchResults$: api.fetchResults$.pipe(
+ takeUntil(this.stop$), // stop the interval when stop method is called
+ catchError(() => Rx.of(null)) // do not throw error
+ ),
+ };
}
- private mount(api$: NewsfeedApiFetchResult, targetDomElement: HTMLElement) {
+ private mount(api: NewsfeedApi, targetDomElement: HTMLElement) {
ReactDOM.render(
-
+
,
targetDomElement
);
From b1c68a42ba19145751268dae2c58b2c8d60752fd Mon Sep 17 00:00:00 2001
From: gchaps <33642766+gchaps@users.noreply.github.com>
Date: Wed, 2 Jun 2021 07:23:39 -0700
Subject: [PATCH 07/77] [DOCS] Adds server.uuid to settings docs (#101121)
---
docs/setup/settings.asciidoc | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc
index 6e8026c4a747df..ddb906f390a2d9 100644
--- a/docs/setup/settings.asciidoc
+++ b/docs/setup/settings.asciidoc
@@ -596,6 +596,10 @@ inactive socket. *Default: `"120000"`*
| Paths to a PEM-encoded X.509 server certificate and its corresponding private key. These
are used by {kib} to establish trust when receiving inbound SSL/TLS connections from users.
+|[[server-uuid]] `server.uuid:`
+ | The unique identifier for this {kib} instance.
+
+
|===
[NOTE]
From f4f8ea73f95d6715473aa869594fecc482040e25 Mon Sep 17 00:00:00 2001
From: Robert Oskamp
Date: Wed, 2 Jun 2021 16:32:53 +0200
Subject: [PATCH 08/77] [ML] Functional tests - reenable categorization tests
(#101137)
This PR re-enables the categorization tests that have been temporarily skipped.
---
.../ml/jobs/categorization_field_examples.ts | 3 +-
.../apis/ml/modules/setup_module.ts | 55 ++++----
.../apis/ml/results/get_categorizer_stats.ts | 3 +-
.../apis/ml/results/get_stopped_partitions.ts | 3 +-
.../apps/ml/anomaly_detection/advanced_job.ts | 133 +++++++++---------
.../anomaly_detection/categorization_job.ts | 3 +-
.../functional/services/ml/test_resources.ts | 4 +-
7 files changed, 99 insertions(+), 105 deletions(-)
diff --git a/x-pack/test/api_integration/apis/ml/jobs/categorization_field_examples.ts b/x-pack/test/api_integration/apis/ml/jobs/categorization_field_examples.ts
index 85072986f1112c..c9adc85b40c1b4 100644
--- a/x-pack/test/api_integration/apis/ml/jobs/categorization_field_examples.ts
+++ b/x-pack/test/api_integration/apis/ml/jobs/categorization_field_examples.ts
@@ -284,8 +284,7 @@ export default ({ getService }: FtrProviderContext) => {
},
];
- // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056
- describe.skip('Categorization example endpoint - ', function () {
+ describe('Categorization example endpoint - ', function () {
before(async () => {
await esArchiver.loadIfNeeded('ml/categorization');
await ml.testResources.setKibanaTimeZoneToUTC();
diff --git a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts
index 6df94c35ab50bb..186a87e5473827 100644
--- a/x-pack/test/api_integration/apis/ml/modules/setup_module.ts
+++ b/x-pack/test/api_integration/apis/ml/modules/setup_module.ts
@@ -241,34 +241,33 @@ export default ({ getService }: FtrProviderContext) => {
dashboards: [] as string[],
},
},
- // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056
- // {
- // testTitleSuffix:
- // 'for logs_ui_categories with prefix, startDatafeed true and estimateModelMemory true',
- // sourceDataArchive: 'ml/module_logs',
- // indexPattern: { name: 'ft_module_logs', timeField: '@timestamp' },
- // module: 'logs_ui_categories',
- // user: USER.ML_POWERUSER,
- // requestBody: {
- // prefix: 'pf7_',
- // indexPatternName: 'ft_module_logs',
- // startDatafeed: true,
- // end: Date.now(),
- // },
- // expected: {
- // responseCode: 200,
- // jobs: [
- // {
- // jobId: 'pf7_log-entry-categories-count',
- // jobState: JOB_STATE.CLOSED,
- // datafeedState: DATAFEED_STATE.STOPPED,
- // },
- // ],
- // searches: [] as string[],
- // visualizations: [] as string[],
- // dashboards: [] as string[],
- // },
- // },
+ {
+ testTitleSuffix:
+ 'for logs_ui_categories with prefix, startDatafeed true and estimateModelMemory true',
+ sourceDataArchive: 'ml/module_logs',
+ indexPattern: { name: 'ft_module_logs', timeField: '@timestamp' },
+ module: 'logs_ui_categories',
+ user: USER.ML_POWERUSER,
+ requestBody: {
+ prefix: 'pf7_',
+ indexPatternName: 'ft_module_logs',
+ startDatafeed: true,
+ end: Date.now(),
+ },
+ expected: {
+ responseCode: 200,
+ jobs: [
+ {
+ jobId: 'pf7_log-entry-categories-count',
+ jobState: JOB_STATE.CLOSED,
+ datafeedState: DATAFEED_STATE.STOPPED,
+ },
+ ],
+ searches: [] as string[],
+ visualizations: [] as string[],
+ dashboards: [] as string[],
+ },
+ },
{
testTitleSuffix: 'for nginx_ecs with prefix, startDatafeed true and estimateModelMemory true',
sourceDataArchive: 'ml/module_nginx',
diff --git a/x-pack/test/api_integration/apis/ml/results/get_categorizer_stats.ts b/x-pack/test/api_integration/apis/ml/results/get_categorizer_stats.ts
index 32a131ded98e18..ef677969d006f0 100644
--- a/x-pack/test/api_integration/apis/ml/results/get_categorizer_stats.ts
+++ b/x-pack/test/api_integration/apis/ml/results/get_categorizer_stats.ts
@@ -51,8 +51,7 @@ export default ({ getService }: FtrProviderContext) => {
query: { bool: { must: [{ match_all: {} }] } },
};
- // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056
- describe.skip('get categorizer_stats', function () {
+ describe('get categorizer_stats', function () {
before(async () => {
await esArchiver.loadIfNeeded('ml/module_sample_logs');
await ml.testResources.setKibanaTimeZoneToUTC();
diff --git a/x-pack/test/api_integration/apis/ml/results/get_stopped_partitions.ts b/x-pack/test/api_integration/apis/ml/results/get_stopped_partitions.ts
index 97e4800f1bedd8..d00999b06b588c 100644
--- a/x-pack/test/api_integration/apis/ml/results/get_stopped_partitions.ts
+++ b/x-pack/test/api_integration/apis/ml/results/get_stopped_partitions.ts
@@ -85,8 +85,7 @@ export default ({ getService }: FtrProviderContext) => {
const testJobIds = testSetUps.map((t) => t.jobId);
- // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056
- describe.skip('get stopped_partitions', function () {
+ describe('get stopped_partitions', function () {
before(async () => {
await esArchiver.loadIfNeeded('ml/module_sample_logs');
await ml.testResources.setKibanaTimeZoneToUTC();
diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts
index 1ff437d441aa81..bc2f3277206907 100644
--- a/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts
+++ b/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts
@@ -149,73 +149,72 @@ export default function ({ getService }: FtrProviderContext) {
},
},
},
- // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056
- // {
- // suiteTitle: 'with categorization detector and default datafeed settings',
- // jobSource: 'ft_ecommerce',
- // jobId: `ec_advanced_2_${Date.now()}`,
- // get jobIdClone(): string {
- // return `${this.jobId}_clone`;
- // },
- // jobDescription:
- // 'Create advanced job from ft_ecommerce dataset with a categorization detector and default datafeed settings',
- // jobGroups: ['automated', 'ecommerce', 'advanced'],
- // get jobGroupsClone(): string[] {
- // return [...this.jobGroups, 'clone'];
- // },
- // pickFieldsConfig: {
- // categorizationField: 'products.product_name',
- // detectors: [
- // {
- // identifier: 'count by mlcategory',
- // function: 'count',
- // byField: 'mlcategory',
- // } as Detector,
- // ],
- // influencers: ['mlcategory'],
- // bucketSpan: '4h',
- // memoryLimit: '100mb',
- // } as PickFieldsConfig,
- // datafeedConfig: {} as DatafeedConfig,
- // expected: {
- // wizard: {
- // timeField: 'order_date',
- // },
- // row: {
- // recordCount: '4,675',
- // memoryStatus: 'ok',
- // jobState: 'closed',
- // datafeedState: 'stopped',
- // latestTimestamp: '2019-07-12 23:45:36',
- // },
- // counts: {
- // processed_record_count: '4,675',
- // processed_field_count: '4,675',
- // input_bytes: '354.2 KB',
- // input_field_count: '4,675',
- // invalid_date_count: '0',
- // missing_field_count: '0',
- // out_of_order_timestamp_count: '0',
- // empty_bucket_count: '0',
- // sparse_bucket_count: '0',
- // bucket_count: '185',
- // earliest_record_timestamp: '2019-06-12 00:04:19',
- // latest_record_timestamp: '2019-07-12 23:45:36',
- // input_record_count: '4,675',
- // latest_bucket_timestamp: '2019-07-12 20:00:00',
- // },
- // modelSizeStats: {
- // result_type: 'model_size_stats',
- // model_bytes_exceeded: '0.0 B',
- // // not checking total_by_field_count as the number of categories might change
- // total_over_field_count: '0',
- // total_partition_field_count: '2',
- // bucket_allocation_failures_count: '0',
- // memory_status: 'ok',
- // timestamp: '2019-07-12 16:00:00',
- // },
- // },
- // },
+ {
+ suiteTitle: 'with categorization detector and default datafeed settings',
+ jobSource: 'ft_ecommerce',
+ jobId: `ec_advanced_2_${Date.now()}`,
+ get jobIdClone(): string {
+ return `${this.jobId}_clone`;
+ },
+ jobDescription:
+ 'Create advanced job from ft_ecommerce dataset with a categorization detector and default datafeed settings',
+ jobGroups: ['automated', 'ecommerce', 'advanced'],
+ get jobGroupsClone(): string[] {
+ return [...this.jobGroups, 'clone'];
+ },
+ pickFieldsConfig: {
+ categorizationField: 'products.product_name',
+ detectors: [
+ {
+ identifier: 'count by mlcategory',
+ function: 'count',
+ byField: 'mlcategory',
+ } as Detector,
+ ],
+ influencers: ['mlcategory'],
+ bucketSpan: '4h',
+ memoryLimit: '100mb',
+ } as PickFieldsConfig,
+ datafeedConfig: {} as DatafeedConfig,
+ expected: {
+ wizard: {
+ timeField: 'order_date',
+ },
+ row: {
+ recordCount: '4,675',
+ memoryStatus: 'ok',
+ jobState: 'closed',
+ datafeedState: 'stopped',
+ latestTimestamp: '2019-07-12 23:45:36',
+ },
+ counts: {
+ processed_record_count: '4,675',
+ processed_field_count: '4,675',
+ input_bytes: '354.2 KB',
+ input_field_count: '4,675',
+ invalid_date_count: '0',
+ missing_field_count: '0',
+ out_of_order_timestamp_count: '0',
+ empty_bucket_count: '0',
+ sparse_bucket_count: '0',
+ bucket_count: '185',
+ earliest_record_timestamp: '2019-06-12 00:04:19',
+ latest_record_timestamp: '2019-07-12 23:45:36',
+ input_record_count: '4,675',
+ latest_bucket_timestamp: '2019-07-12 20:00:00',
+ },
+ modelSizeStats: {
+ result_type: 'model_size_stats',
+ model_bytes_exceeded: '0.0 B',
+ // not checking total_by_field_count as the number of categories might change
+ total_over_field_count: '0',
+ total_partition_field_count: '2',
+ bucket_allocation_failures_count: '0',
+ memory_status: 'ok',
+ timestamp: '2019-07-12 16:00:00',
+ },
+ },
+ },
];
const calendarId = `wizard-test-calendar_${Date.now()}`;
diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts
index 144199136a6dca..85eeacc58514e2 100644
--- a/x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts
+++ b/x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts
@@ -74,8 +74,7 @@ export default function ({ getService }: FtrProviderContext) {
const calendarId = `wizard-test-calendar_${Date.now()}`;
- // skipping categorization tests, see https://github.com/elastic/kibana/issues/101056
- describe.skip('categorization', function () {
+ describe('categorization', function () {
this.tags(['mlqa']);
before(async () => {
await esArchiver.loadIfNeeded('ml/categorization');
diff --git a/x-pack/test/functional/services/ml/test_resources.ts b/x-pack/test/functional/services/ml/test_resources.ts
index a8db7ccb7a7648..a857809a3079f2 100644
--- a/x-pack/test/functional/services/ml/test_resources.ts
+++ b/x-pack/test/functional/services/ml/test_resources.ts
@@ -534,7 +534,7 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider
},
async installFleetPackage(packageIdentifier: string) {
- log.debug(`Installing Fleet package'${packageIdentifier}'`);
+ log.debug(`Installing Fleet package '${packageIdentifier}'`);
await supertest
.post(`/api/fleet/epm/packages/${packageIdentifier}`)
@@ -545,7 +545,7 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider
},
async removeFleetPackage(packageIdentifier: string) {
- log.debug(`Removing Fleet package'${packageIdentifier}'`);
+ log.debug(`Removing Fleet package '${packageIdentifier}'`);
await supertest
.delete(`/api/fleet/epm/packages/${packageIdentifier}`)
From ad09cdc5091d81373d1dc68c8332ae950eb48a19 Mon Sep 17 00:00:00 2001
From: James Gowdy
Date: Wed, 2 Jun 2021 15:33:49 +0100
Subject: [PATCH 09/77] [Home] Adding file upload to add data page (#100863)
* [Home] Adding file upload to add data page
* updating comment
* tiny refactor
* attempting to reduce bundle size
* reverting to original home register import
* lazy load tab contents
* changes based on review
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../components/tutorial_directory.js | 13 ++++-
.../public/application/kibana_services.ts | 2 +
src/plugins/home/public/mocks.ts | 2 +
src/plugins/home/public/plugin.test.mocks.ts | 3 ++
src/plugins/home/public/plugin.ts | 9 ++++
.../add_data/add_data_service.mock.ts | 31 ++++++++++++
.../add_data/add_data_service.test.tsx | 49 +++++++++++++++++++
.../services/add_data/add_data_service.ts | 40 +++++++++++++++
.../home/public/services/add_data/index.ts | 11 +++++
src/plugins/home/public/services/index.ts | 3 ++
.../plugins/file_data_visualizer/kibana.json | 3 +-
.../application/file_datavisualizer.tsx | 4 ++
.../lazy_load_bundle/component_wrapper.tsx | 18 +++++++
.../file_data_visualizer/public/plugin.ts | 17 +++++--
.../public/register_home.ts | 20 ++++++++
15 files changed, 217 insertions(+), 8 deletions(-)
create mode 100644 src/plugins/home/public/services/add_data/add_data_service.mock.ts
create mode 100644 src/plugins/home/public/services/add_data/add_data_service.test.tsx
create mode 100644 src/plugins/home/public/services/add_data/add_data_service.ts
create mode 100644 src/plugins/home/public/services/add_data/index.ts
create mode 100644 x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/component_wrapper.tsx
create mode 100644 x-pack/plugins/file_data_visualizer/public/register_home.ts
diff --git a/src/plugins/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js
index 094ac302fcebeb..1fda865ebd8476 100644
--- a/src/plugins/home/public/application/components/tutorial_directory.js
+++ b/src/plugins/home/public/application/components/tutorial_directory.js
@@ -42,6 +42,8 @@ class TutorialDirectoryUi extends React.Component {
constructor(props) {
super(props);
+ const extraTabs = getServices().addDataService.getAddDataTabs();
+
this.tabs = [
{
id: ALL_TAB_ID,
@@ -77,7 +79,13 @@ class TutorialDirectoryUi extends React.Component {
id: 'home.tutorial.tabs.sampleDataTitle',
defaultMessage: 'Sample data',
}),
+ content: ,
},
+ ...extraTabs.map(({ id, name, component: Component }) => ({
+ id,
+ name,
+ content: ,
+ })),
];
let openTab = ALL_TAB_ID;
@@ -190,8 +198,9 @@ class TutorialDirectoryUi extends React.Component {
};
renderTabContent = () => {
- if (this.state.selectedTabId === SAMPLE_DATA_TAB_ID) {
- return ;
+ const tab = this.tabs.find(({ id }) => id === this.state.selectedTabId);
+ if (tab?.content) {
+ return tab.content;
}
return (
diff --git a/src/plugins/home/public/application/kibana_services.ts b/src/plugins/home/public/application/kibana_services.ts
index 73a8ab41bcfd29..af9f956889547d 100644
--- a/src/plugins/home/public/application/kibana_services.ts
+++ b/src/plugins/home/public/application/kibana_services.ts
@@ -20,6 +20,7 @@ import { UiCounterMetricType } from '@kbn/analytics';
import { TelemetryPluginStart } from '../../../telemetry/public';
import { UrlForwardingStart } from '../../../url_forwarding/public';
import { TutorialService } from '../services/tutorials';
+import { AddDataService } from '../services/add_data';
import { FeatureCatalogueRegistry } from '../services/feature_catalogue';
import { EnvironmentService } from '../services/environment';
import { ConfigSchema } from '../../config';
@@ -44,6 +45,7 @@ export interface HomeKibanaServices {
environmentService: EnvironmentService;
telemetry?: TelemetryPluginStart;
tutorialService: TutorialService;
+ addDataService: AddDataService;
}
let services: HomeKibanaServices | null = null;
diff --git a/src/plugins/home/public/mocks.ts b/src/plugins/home/public/mocks.ts
index 32bec31153ba0d..10c186ee3f4e30 100644
--- a/src/plugins/home/public/mocks.ts
+++ b/src/plugins/home/public/mocks.ts
@@ -10,11 +10,13 @@ import { featureCatalogueRegistryMock } from './services/feature_catalogue/featu
import { environmentServiceMock } from './services/environment/environment.mock';
import { configSchema } from '../config';
import { tutorialServiceMock } from './services/tutorials/tutorial_service.mock';
+import { addDataServiceMock } from './services/add_data/add_data_service.mock';
const createSetupContract = () => ({
featureCatalogue: featureCatalogueRegistryMock.createSetup(),
environment: environmentServiceMock.createSetup(),
tutorials: tutorialServiceMock.createSetup(),
+ addData: addDataServiceMock.createSetup(),
config: configSchema.validate({}),
});
diff --git a/src/plugins/home/public/plugin.test.mocks.ts b/src/plugins/home/public/plugin.test.mocks.ts
index 779ab2e7003526..c3e3c50a2fe0f3 100644
--- a/src/plugins/home/public/plugin.test.mocks.ts
+++ b/src/plugins/home/public/plugin.test.mocks.ts
@@ -9,12 +9,15 @@
import { featureCatalogueRegistryMock } from './services/feature_catalogue/feature_catalogue_registry.mock';
import { environmentServiceMock } from './services/environment/environment.mock';
import { tutorialServiceMock } from './services/tutorials/tutorial_service.mock';
+import { addDataServiceMock } from './services/add_data/add_data_service.mock';
export const registryMock = featureCatalogueRegistryMock.create();
export const environmentMock = environmentServiceMock.create();
export const tutorialMock = tutorialServiceMock.create();
+export const addDataMock = addDataServiceMock.create();
jest.doMock('./services', () => ({
FeatureCatalogueRegistry: jest.fn(() => registryMock),
EnvironmentService: jest.fn(() => environmentMock),
TutorialService: jest.fn(() => tutorialMock),
+ AddDataService: jest.fn(() => addDataMock),
}));
diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts
index 89c7600a1d85d9..b3b5ce487b747d 100644
--- a/src/plugins/home/public/plugin.ts
+++ b/src/plugins/home/public/plugin.ts
@@ -24,6 +24,8 @@ import {
FeatureCatalogueRegistrySetup,
TutorialService,
TutorialServiceSetup,
+ AddDataService,
+ AddDataServiceSetup,
} from './services';
import { ConfigSchema } from '../config';
import { setServices } from './application/kibana_services';
@@ -56,6 +58,7 @@ export class HomePublicPlugin
private readonly featuresCatalogueRegistry = new FeatureCatalogueRegistry();
private readonly environmentService = new EnvironmentService();
private readonly tutorialService = new TutorialService();
+ private readonly addDataService = new AddDataService();
constructor(private readonly initializerContext: PluginInitializerContext) {}
@@ -94,6 +97,7 @@ export class HomePublicPlugin
urlForwarding: urlForwardingStart,
homeConfig: this.initializerContext.config.get(),
tutorialService: this.tutorialService,
+ addDataService: this.addDataService,
featureCatalogue: this.featuresCatalogueRegistry,
});
coreStart.chrome.docTitle.change(
@@ -126,6 +130,7 @@ export class HomePublicPlugin
featureCatalogue,
environment: { ...this.environmentService.setup() },
tutorials: { ...this.tutorialService.setup() },
+ addData: { ...this.addDataService.setup() },
};
}
@@ -163,9 +168,13 @@ export type EnvironmentSetup = EnvironmentServiceSetup;
/** @public */
export type TutorialSetup = TutorialServiceSetup;
+/** @public */
+export type AddDataSetup = AddDataServiceSetup;
+
/** @public */
export interface HomePublicPluginSetup {
tutorials: TutorialServiceSetup;
+ addData: AddDataServiceSetup;
featureCatalogue: FeatureCatalogueSetup;
/**
* The environment service is only available for a transition period and will
diff --git a/src/plugins/home/public/services/add_data/add_data_service.mock.ts b/src/plugins/home/public/services/add_data/add_data_service.mock.ts
new file mode 100644
index 00000000000000..e0b4d129097919
--- /dev/null
+++ b/src/plugins/home/public/services/add_data/add_data_service.mock.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
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type { PublicMethodsOf } from '@kbn/utility-types';
+import { AddDataService, AddDataServiceSetup } from './add_data_service';
+
+const createSetupMock = (): jest.Mocked => {
+ const setup = {
+ registerAddDataTab: jest.fn(),
+ };
+ return setup;
+};
+
+const createMock = (): jest.Mocked> => {
+ const service = {
+ setup: jest.fn(),
+ getAddDataTabs: jest.fn(() => []),
+ };
+ service.setup.mockImplementation(createSetupMock);
+ return service;
+};
+
+export const addDataServiceMock = {
+ createSetup: createSetupMock,
+ create: createMock,
+};
diff --git a/src/plugins/home/public/services/add_data/add_data_service.test.tsx b/src/plugins/home/public/services/add_data/add_data_service.test.tsx
new file mode 100644
index 00000000000000..b04b80ac19eec5
--- /dev/null
+++ b/src/plugins/home/public/services/add_data/add_data_service.test.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
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+import { AddDataService } from './add_data_service';
+
+describe('AddDataService', () => {
+ describe('setup', () => {
+ test('allows multiple register directory header link calls', () => {
+ const setup = new AddDataService().setup();
+ expect(() => {
+ setup.registerAddDataTab({ id: 'abc', name: 'a b c', component: () => 123 });
+ setup.registerAddDataTab({ id: 'def', name: 'a b c', component: () => 456 });
+ }).not.toThrow();
+ });
+
+ test('throws when same directory header link is registered twice', () => {
+ const setup = new AddDataService().setup();
+ expect(() => {
+ setup.registerAddDataTab({ id: 'abc', name: 'a b c', component: () => 123 });
+ setup.registerAddDataTab({ id: 'abc', name: 'a b c', component: () => 456 });
+ }).toThrow();
+ });
+ });
+
+ describe('getDirectoryHeaderLinks', () => {
+ test('returns empty array', () => {
+ const service = new AddDataService();
+ expect(service.getAddDataTabs()).toEqual([]);
+ });
+
+ test('returns last state of register calls', () => {
+ const service = new AddDataService();
+ const setup = service.setup();
+ const links = [
+ { id: 'abc', name: 'a b c', component: () => 123 },
+ { id: 'def', name: 'a b c', component: () => 456 },
+ ];
+ setup.registerAddDataTab(links[0]);
+ setup.registerAddDataTab(links[1]);
+ expect(service.getAddDataTabs()).toEqual(links);
+ });
+ });
+});
diff --git a/src/plugins/home/public/services/add_data/add_data_service.ts b/src/plugins/home/public/services/add_data/add_data_service.ts
new file mode 100644
index 00000000000000..668c373f8314d3
--- /dev/null
+++ b/src/plugins/home/public/services/add_data/add_data_service.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React from 'react';
+
+/** @public */
+export interface AddDataTab {
+ id: string;
+ name: string;
+ component: React.FC;
+}
+
+export class AddDataService {
+ private addDataTabs: Record = {};
+
+ public setup() {
+ return {
+ /**
+ * Registers a component that will be rendered as a new tab in the Add data page
+ */
+ registerAddDataTab: (tab: AddDataTab) => {
+ if (this.addDataTabs[tab.id]) {
+ throw new Error(`Tab ${tab.id} already exists`);
+ }
+ this.addDataTabs[tab.id] = tab;
+ },
+ };
+ }
+
+ public getAddDataTabs() {
+ return Object.values(this.addDataTabs);
+ }
+}
+
+export type AddDataServiceSetup = ReturnType;
diff --git a/src/plugins/home/public/services/add_data/index.ts b/src/plugins/home/public/services/add_data/index.ts
new file mode 100644
index 00000000000000..f2367ca320e9fa
--- /dev/null
+++ b/src/plugins/home/public/services/add_data/index.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export { AddDataService } from './add_data_service';
+
+export type { AddDataServiceSetup, AddDataTab } from './add_data_service';
diff --git a/src/plugins/home/public/services/index.ts b/src/plugins/home/public/services/index.ts
index 8cd4c8d84e0f78..65913df6310b19 100644
--- a/src/plugins/home/public/services/index.ts
+++ b/src/plugins/home/public/services/index.ts
@@ -26,3 +26,6 @@ export type {
TutorialDirectoryHeaderLinkComponent,
TutorialModuleNoticeComponent,
} from './tutorials';
+
+export { AddDataService } from './add_data';
+export type { AddDataServiceSetup, AddDataTab } from './add_data';
diff --git a/x-pack/plugins/file_data_visualizer/kibana.json b/x-pack/plugins/file_data_visualizer/kibana.json
index 721352cff7c957..eea52bb6e98b2c 100644
--- a/x-pack/plugins/file_data_visualizer/kibana.json
+++ b/x-pack/plugins/file_data_visualizer/kibana.json
@@ -14,7 +14,8 @@
],
"optionalPlugins": [
"security",
- "maps"
+ "maps",
+ "home"
],
"requiredBundles": [
"kibanaReact",
diff --git a/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx b/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx
index f291076557bb83..c8f327496842a0 100644
--- a/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx
+++ b/x-pack/plugins/file_data_visualizer/public/application/file_datavisualizer.tsx
@@ -28,3 +28,7 @@ export const FileDataVisualizer: FC = () => {
);
};
+
+// exporting as default so it can be used with React.lazy
+// eslint-disable-next-line import/no-default-export
+export default FileDataVisualizer;
diff --git a/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/component_wrapper.tsx b/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/component_wrapper.tsx
new file mode 100644
index 00000000000000..e6835d9e7a668b
--- /dev/null
+++ b/x-pack/plugins/file_data_visualizer/public/lazy_load_bundle/component_wrapper.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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { FC } from 'react';
+
+const FileDataVisualizerComponent = React.lazy(() => import('../application/file_datavisualizer'));
+
+export const FileDataVisualizerWrapper: FC = () => {
+ return (
+ }>
+
+
+ );
+};
diff --git a/x-pack/plugins/file_data_visualizer/public/plugin.ts b/x-pack/plugins/file_data_visualizer/public/plugin.ts
index a94c0fce45cd40..0064f96195eafb 100644
--- a/x-pack/plugins/file_data_visualizer/public/plugin.ts
+++ b/x-pack/plugins/file_data_visualizer/public/plugin.ts
@@ -5,21 +5,24 @@
* 2.0.
*/
-import { CoreStart } from 'kibana/public';
+import { CoreSetup, CoreStart } from 'kibana/public';
import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public';
import type { SharePluginStart } from '../../../../src/plugins/share/public';
import { Plugin } from '../../../../src/core/public';
import { setStartServices } from './kibana_services';
-import { DataPublicPluginStart } from '../../../../src/plugins/data/public';
+import type { DataPublicPluginStart } from '../../../../src/plugins/data/public';
+import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public';
import type { FileUploadPluginStart } from '../../file_upload/public';
import type { MapsStartApi } from '../../maps/public';
import type { SecurityPluginSetup } from '../../security/public';
import { getFileDataVisualizerComponent } from './api';
import { getMaxBytesFormatted } from './application/util/get_max_bytes';
+import { registerHomeAddData } from './register_home';
-// eslint-disable-next-line @typescript-eslint/no-empty-interface
-export interface FileDataVisualizerSetupDependencies {}
+export interface FileDataVisualizerSetupDependencies {
+ home?: HomePublicPluginSetup;
+}
export interface FileDataVisualizerStartDependencies {
data: DataPublicPluginStart;
fileUpload: FileUploadPluginStart;
@@ -40,7 +43,11 @@ export class FileDataVisualizerPlugin
FileDataVisualizerSetupDependencies,
FileDataVisualizerStartDependencies
> {
- public setup() {}
+ public setup(core: CoreSetup, plugins: FileDataVisualizerSetupDependencies) {
+ if (plugins.home) {
+ registerHomeAddData(plugins.home);
+ }
+ }
public start(core: CoreStart, plugins: FileDataVisualizerStartDependencies) {
setStartServices(core, plugins);
diff --git a/x-pack/plugins/file_data_visualizer/public/register_home.ts b/x-pack/plugins/file_data_visualizer/public/register_home.ts
new file mode 100644
index 00000000000000..e54c37a8d06bc0
--- /dev/null
+++ b/x-pack/plugins/file_data_visualizer/public/register_home.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public';
+import { FileDataVisualizerWrapper } from './lazy_load_bundle/component_wrapper';
+
+export function registerHomeAddData(home: HomePublicPluginSetup) {
+ home.addData.registerAddDataTab({
+ id: 'fileDataViz',
+ name: i18n.translate('xpack.fileDataVisualizer.embeddedTabTitle', {
+ defaultMessage: 'Upload file',
+ }),
+ component: FileDataVisualizerWrapper,
+ });
+}
From d65b8cb65d7bb79ada9f1504c79489fe7f616c1a Mon Sep 17 00:00:00 2001
From: Robert Austin
Date: Wed, 2 Jun 2021 10:57:28 -0400
Subject: [PATCH 10/77] Fix cases plugin ownership (#101073)
Changed `case` to `cases` to match plugin name
---
.github/CODEOWNERS | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 2c2f6fa11841a6..b071e06f1bc54e 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -340,7 +340,7 @@
#CC# /x-pack/plugins/security_solution/ @elastic/security-solution
# Security Solution sub teams
-/x-pack/plugins/case @elastic/security-threat-hunting
+/x-pack/plugins/cases @elastic/security-threat-hunting
/x-pack/plugins/timelines @elastic/security-threat-hunting
/x-pack/test/case_api_integration @elastic/security-threat-hunting
/x-pack/plugins/lists @elastic/security-detections-response
From ef9d2bfb013325f016a88b1f85db6aa27e13ae4d Mon Sep 17 00:00:00 2001
From: spalger
Date: Wed, 2 Jun 2021 08:16:25 -0700
Subject: [PATCH 11/77] skip flaky suite (#101126)
---
.../public/batching/create_streaming_batched_function.test.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
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 01cebcb15963b3..458b691573e56b 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
@@ -48,7 +48,8 @@ const setup = () => {
};
};
-describe('createStreamingBatchedFunction()', () => {
+// FLAKY: https://github.com/elastic/kibana/issues/101126
+describe.skip('createStreamingBatchedFunction()', () => {
test('returns a function', () => {
const { fetchStreaming } = setup();
const fn = createStreamingBatchedFunction({
From 65dbcdd27d1f093b6811c73edad5c5a527b67aba Mon Sep 17 00:00:00 2001
From: Joe Reuter
Date: Wed, 2 Jun 2021 17:24:56 +0200
Subject: [PATCH 12/77] change label behavior (#100991)
---
.../operations/layer_helpers.test.ts | 35 +++++++++++++++++++
.../operations/layer_helpers.ts | 12 ++++---
2 files changed, 42 insertions(+), 5 deletions(-)
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts
index a53a6e00810b89..38bc84ae9af357 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts
@@ -965,6 +965,41 @@ describe('state_helpers', () => {
);
});
+ it('should not carry over label when operation and field change at the same time', () => {
+ expect(
+ replaceColumn({
+ layer: {
+ indexPatternId: '1',
+ columnOrder: ['col1'],
+ columns: {
+ col1: {
+ label: 'My custom label',
+ customLabel: true,
+ dataType: 'date',
+ isBucketed: true,
+
+ // Private
+ operationType: 'date_histogram',
+ sourceField: 'timestamp',
+ params: {
+ interval: 'h',
+ },
+ },
+ },
+ },
+ indexPattern,
+ columnId: 'col1',
+ op: 'terms',
+ field: indexPattern.fields[4],
+ visualizationGroups: [],
+ }).columns.col1
+ ).toEqual(
+ expect.objectContaining({
+ label: 'Top values of source',
+ })
+ );
+ });
+
it('should carry over label on operation switch when customLabel flag on previousColumn is set', () => {
expect(
replaceColumn({
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 b42cdbd24e656b..56fbb8edef5b43 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
@@ -838,12 +838,14 @@ function applyReferenceTransition({
);
}
-function copyCustomLabel(
- newColumn: IndexPatternColumn,
- previousOptions: { customLabel?: boolean; label: string }
-) {
+function copyCustomLabel(newColumn: IndexPatternColumn, previousOptions: IndexPatternColumn) {
const adjustedColumn = { ...newColumn };
- if (previousOptions.customLabel) {
+ const operationChanged = newColumn.operationType !== previousOptions.operationType;
+ const fieldChanged =
+ ('sourceField' in newColumn && newColumn.sourceField) !==
+ ('sourceField' in previousOptions && previousOptions.sourceField);
+ // only copy custom label if either used operation or used field stayed the same
+ if (previousOptions.customLabel && (!operationChanged || !fieldChanged)) {
adjustedColumn.customLabel = true;
adjustedColumn.label = previousOptions.label;
}
From 119969e11661a4c7274b5068ba4e601d2d6971ca Mon Sep 17 00:00:00 2001
From: Adam Locke
Date: Wed, 2 Jun 2021 11:26:34 -0400
Subject: [PATCH 13/77] [DOCS] Clarify when to use kbn clean (#101155)
When building a PR locally, I ran into an issue where the server kept crashing. I ran `yarn kbn clean`, and saw this message in my terminal:
>warn This command is only necessary for the rare circumstance where you need to recover a consistent state when problems arise. If you need to run this command often, >please let us know by filling out this form: https://ela.st/yarn-kbn-clean
I think it makes sense to add this information to the docs so that if users are reading it, they know that this command is not typically necessary.
---
docs/developer/getting-started/index.asciidoc | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc
index 5ab05812019591..ac8eff132fcfe8 100644
--- a/docs/developer/getting-started/index.asciidoc
+++ b/docs/developer/getting-started/index.asciidoc
@@ -92,6 +92,10 @@ may need to run:
yarn kbn clean
----
+NOTE: Running this command is only necessary in rare circumstance where you need to recover
+a consistent state when problems arise. If you need to run this command often, complete
+this form to provide feedback: https://ela.st/yarn-kbn-clean
+
If you have failures during `yarn kbn bootstrap` you may have some
corrupted packages in your yarn cache which you can clean with:
From 90f2d094ce1a1262b5af63ad354ec6fefe062b9f Mon Sep 17 00:00:00 2001
From: Byron Hulcher
Date: Wed, 2 Jun 2021 11:36:40 -0400
Subject: [PATCH 14/77] [App Search] Crawler Landing Page (#100822)
* New CrawlerLanding component
* New CrawlerRouter component
* Adding CrawlerRouter to EngineRouter
* Using internal route for Crawler link in EngineNav
* Rename crawler landing background
* Fix CrawlerLanding css
* Fix crawler documentation link
* Add Crawler title to breadcrumbs
* Reduce png filesize
* Improve CrawlerLanding copy
* Update x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.scss
Co-authored-by: Constance
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Constance
---
.../crawler/assets/bg_crawler_landing.png | Bin 0 -> 14495 bytes
.../components/crawler/constants.ts | 2 +-
.../components/crawler/crawler_landing.scss | 13 +++
.../crawler/crawler_landing.test.tsx | 43 +++++++++
.../components/crawler/crawler_landing.tsx | 85 ++++++++++++++++++
.../crawler/crawler_router.test.tsx | 34 +++++++
.../components/crawler/crawler_router.tsx | 27 ++++++
.../app_search/components/crawler/index.ts | 1 +
.../components/engine/engine_nav.tsx | 4 +-
.../components/engine/engine_router.test.tsx | 8 ++
.../components/engine/engine_router.tsx | 10 ++-
11 files changed, 221 insertions(+), 6 deletions(-)
create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/assets/bg_crawler_landing.png
create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.scss
create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx
create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/assets/bg_crawler_landing.png b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/assets/bg_crawler_landing.png
new file mode 100644
index 0000000000000000000000000000000000000000..e1f14ac26f353bd601d36f9e436c2faf164c24ff
GIT binary patch
literal 14495
zcmZX*by!s07dAXdN=Qkkgbdv!Azey$BQPQ%ozl`FIe>HvNDnRDFqE`(4h;hXf*{Cu
zp5OPrf4y^^>wj-RoZWUVHYLxX#3AX($umQsaU^AOckt1sxCw6AA)7d5eP%cubUM
zrvRf>>#d$5VD9gq?fiEjcMg#o8_3-w&vp(@fwNJ2?atvj;@|=Z?H&Aw1OWf>Ad&m2-9YZnF>-tV)S#no_W)_oUVD(%
z2aFF>d-d9Dfx%_BJV1f~FbL%KAyDpQXYW+2trkc`b;}{UWe!vOc8||@_mHS=S>5jc
zQg#l2u^fJ#}@#2Ke1atKwu}aTe<9Vzjyhd1P(a={{yH9RR6Rw
z<+nZs4EDc$^(}wlY&GyV{%D22>%AE&x9ufRB$4ND-BNE%))mLDdvc9bgj_6jb`(
zdB1W}uc7P^+Na-M3*-V(0IX7m0-6Jm14uwTs#D&us~!mL?i>UCkwJN3I%LU9c29gQhxU-Al43!puGqEvq1NN72nV3-d#IX`RuQmp9ZuAsCCgLd)2LQ
z*(<+|I5`H8O|w7;_A4f2tj+hkTY!cDecnAdJOBC_00RPm(g*%Vz?1=Phr#AYpRG^7
zxFex~XGy;M3-eM2x@s|>#Z?p$yZeZZ&C{CvUS$@dr}@2J$1L#L?ex^q&;>Du
z$ZQaGTZQCXAzkwMWbzr2NmDW#`HYse-Rnd$-I@jex1JaV7eP+?Wbsyhk1fpKMGO1T!)+
z5+RYujlmErD@z%fzsTKDgm`90$&BJ6t63}Dv@(~aH_m9urE>}z%
za6uTlA1{JPupLZ&E{XY+R?Px6258&w*LIZe0V$L%G60nF
zr};HMIu?X`!50UF(dfd2PFep`soh_`qx
z*Zv%rIYJ;rNXaxHI?RE_Wqaqp2muiBZ|rz=iBB9N^wKnU*H<^Oz>FP=w!5`#0evRx
z2U8*jMUVd88?mJ+ZvELOLK`Bve9iPrnihc>@|Hf~G{U?5ITZCo=4GCEvo{VLN&yn^
z`;=!pDBW4<`xVR!vekLykHBjrEQ|OLG+1v&`5+OY=7+|Z+Is=keGC*U?Eodgy&k8M
zjcmGT08%~Ygy6T_XLg7I%Ue)M(S$L%!?>XnQXRxtQ}Gm*JQTo7e}a)T)=Kyl7@IgD
zc|b&8tMlAjM<42{-rja!v>z)yod(Ol>lB0l5r{q%!a_bq#gaUFCZ1We~xitI{t;LG-yea
zR-EEyUR~DaZK9~C&URxrYTSOYpsW)1Upnz0pW`b=6jFxEtwobzVYAejJ@=Hrb1O95
z!t^5$U))oE_$sn|)Tk-Rw*R;??rbGOXqR`{#RhuCHq%t!KSY%tCIGrOFQv^SQ1`FT
zY}o-cKI*XlowM-VD(D!8-N(hnWmpE=W}*?jdRLnB;Sv?ojg5d@yEoPF0!;*6~ygpat_(=XKPm-`joH>6eKs^!7b6roEj(;}Dg9Qal
z!Hn#>F_r>LCS$_(p-*PTg5`_>s_CKPyg*5t5&Ep1*Z+;*3#q|pM|3S_*->M7m2zC@
z1P{~45{qPQSG!0B5m@>hO;O+S#b9zrof=t2P*3`Fo%E2hkA_#p_ba+LQS+aic@uUc
z^f{v;n;C?^lbvtAFxyr#GauS0B#*^02gll~;med5GMuKl^&2%~=Kgceut5|3$7EfC3=V5>`krGz(;6^wqv$=`
zTy93i=(@oM9&b|Mi9i_%)AOfxx#a)wpSW2YTx~Ajtc&mQVuM0%0_tI=2W@~`*Wed<
zn$Q@IsgIno*Hp{x;CF(v(4Cb48ar>^EJ%o!vsx);7{`UKva+$a?PZSNN@~PShc?IXaEmvc;U#aG^BrOK&*l2_^NXzE?shO8Heyd4=)!i3^4rt_^4&XPME`>vz}tUEW%F{
z#pv}CT2|yEF+Ab^V*Gl$5~1#ovls{}y#I2-p&>@ifBC&rEMQHU13erTF#4(fZ@PQy
z>EI(}AD_$z>cUkd-A^9@D5Rf4cdKK$ui<(-*y)Cfk?)IE!ZA7%o4hzl_38vPf1l>l
zqD2y1hP052x=XC%m9+-vwVg+}9@f8jEoYBE)+~}E8uz|8pY^b=nV~DRU9Fq}Up^d-
zwo^o|NLw+;wM}frRXB?j84{)r+!{&*BU9HBN5vS=wG0R>$9GTVUUywE-fDOL3F9Uy
zkbMr{@yA|u<%cS-5QI_O1Q^D5@1eMNl38gMd-S;yPrf=$@cwa|YB^OxjsGcGTZ~b&
zV3Xr}MFP)XYR_MM`5F9d)1|>V%n1G4g;hKe4Lh|q=fsOxYAwB=Bqa_%L#PSY2Xrxd
znG7W7J`sm6vR1g+22o32uZYOE{&d?{9T>hS=?W_S%0e_6yTP1
zH%XBz=9F;a3gY!+nu{u=-CRl*JGmTO^D_Mw<@=kSHT-2^$3JDEV9}tBt$;xfzGuT}
z+HwcPaH=~9(S?r$8iAP88=*jYBDE({a^l}(T8sAC!9trS@!ZowK_~Jt1gr^qB~@3&E4`PC1^@NeN|vO0_du@Yu)|H3bvt-dxG&*;8-
zzAajMy7By)nh5FzlM#Zx#yk+khrn$W47b@99wpG38p+qT@`PWc(=U^#TVjV+N$lo-
zMhm56MNL65L*KrS`G(?eFDAn_33ulx;zvmJ@mdCWJ_s?UZ@0@??~XiM!pBG&B_R>rceyM>oiXq#ysN2d8mnRL-}(b_R`Q)LLX`XTR=V(H6(*+r@dB
z4OdF{-L0~<6L^7HmG)v=3oEQwhZkDm2;XMghB3;mcIp;rK5)Z7-&pLs+Ag6Ne+jF4~an{ATa{
zu(FTiEV=!i7!Lm%u(1_5oX|b>;Mp4pKE#;qtW2RBvuPLB`0nj@$ndbDcg0EV-#^)(
zY}~}<#=?;b&%Z9!O5qVj!lLTeTd)VZ(~
zFYqCWO!GHnufOFalfoHfel%r~_5?(jb2NMm)GgObCxe^Rpx%M~-+>8L{O`JO6WKY9
z*bObkdsm+X=Su{R8yS4lYgYP`==&-BS~2ef;l}eJt@MVUcD_TXeQyv?
z81W(ErVH#Bi%^=QfcC&gdr>9r!l)D_JYLJdt1wZY
zgSS~GMc7WLg);xn_8aPom8f?-^<$|j4p;8+Vl=Cu!kuHAyiT7>Kp-XjUl}jd{NUJF
zdy9HHOYl3#aGCV0wBg4Jmu%y=6x=w20<(v2M>cF@?I)gJ5D~McXo={^XF5c}ZnzyI
zDDL$9CO%D48E4*BXj)j_Vlb946QnCM{ZRI_4a6oYlRk??5k$$S?SQZ#Hut81IPBQ+
znbTy%8&nmEEgBYIeZ-(krhzjrcg~*3F^l;pk>dAh(I;K=_?EXr9t{1Hg9N-VSt`aQ
z%)Zk;;4RynSTZiv8c%~#%j>o8{Wbs;KLrAAaXo%30~sU!d+1z@A^ot&fI
zfg)jxwnEC=v2%EVPnwn=MQbuLldA?c2QA5JZt?DH7_NDf^st@Kdhq0pClQlEUUza;
zdphkGTRTT;q%0=o;vMuCSG*s1Pp2n9a+Gk~D3tB<;AR(GA#Ly0`JSl>4Wn$@hdA^Q
zVB>r$4Djqk;$M{qBz`#Aqec5JdBgj`rTcQVTLwp7oWD>K(WYDy5#gsCA~0O3^?85W
z8C|aXS9`WTn>Anj4$`v{{kmB_J~OBMwthbaeC^
z%(d3@CuU{nqb6%!2sB>x&{yO!fe5FuoWSMgck>?=7b^t`X3aZ|j~v5G+)-7i53OYPv6B?eVK?e|W)Wy$}H
zAHLGQQc$x90(%7I%&k8~C#B_(f1w~=1%6X2YkY4^@p=v7$EG?uMF^1=$ksFX`&ktN
z4goX6or^mLP$If$3;;bcQIZ0yD_iN78;ubj4O;`d6i2j
z*0qm@y@z(@bpC-XofkDN6PPt(HK{53O?ivd=IT)wb!>tyx4pmM=Qjc!U@MfV*3tfE
zyUt-*h$4dIg8%xLjPs^02fnlR3dt1H`fdPaZ)=aqJT9{ZX)naUx|QKMsmpKP?GFDM
zp?ea|4ew?dI=Xs%0dJ7nPB*WUD(8~6(xQTXr$E#JKDQO=OiAf&YaKscpqqKJP=bH^
z9^#~I@g2jb#IBkND65P}{-SvBir42A@3rew;tPTn<*Rv7`X!=FW(iUby
z1S-s+QEdk8W`a{&KWa_FcvOiHKe-s;>IzhraElM`r&r#RD2NMv6xFtv`ln{Dbjvad
zQ22d!Chrs(B|`L6%{QpNQxJ#v9jju_QbfBqz2^;DQGb+!hBd5D=!ViA21ONv8JNiu
z_n{L-@!}AL+it?acQ2A-AGOcgV%_^t3&|d1tIe~mk~&zE(BHWw>_mt%^xJ@RUI!@bf`dt`KGsZF*id9f`2rjl{3lDigDd1!hh$ri1BXbPrtKPk{zT
zv9^L;to@!={At(p8_W)blf}7bDLM-RYJUR6vIgAZG^`fGsI1-Pq?PL!XdVtT;RPx>
zo!NNKo9#dTuge=#OMFGE@BPf(&z`rOr2H1WbB_OlUv7PL%4J>s5+K?XT2&DD5>Z>7
zwkSVA7NC-#^f=5d=UGj8p9Y5EXW3lJevi@w=n8F6R?)?@>`A2<|NpkfdV*yRYg0zw
zNM(Jpqn=cg5rvf}iB2yfxj-z|UEm{BY);sXt<@Y`<+Psf!qe$Jp1u}rF40-2X1;b_
z6}bGUAH9zn$YU=a=yhhH&O;b*r!M2Y+g|!LF8xFXDm8n5v$=S4Q}BcJ%-H`&%7?)_
z?qgG%s;)ny>z(al#ES1gue+?@5i#Ht;ZU=D;8L(A_WDd&?M=gt!L4U&?Bs;WY6Vdv
zmKaoatcWm3>E|QXn;V$_9plJ)=K_`{vh#U=mfObW@%}!@OXb#0{6@lukwmV|h@6|x
zM(d#frFJRhSS^o8zvMn1i;s|w4$J_e>n7{ua$JNpMMn+z138t}X1P`@^R@-YJanTr|
zU!Z)@zq@l-*^9h-1bmWA8HAQOd(>t65(*$32s7(bgsZl3U66L6+u;eJfc>jni`8v>
zhsvj*LZ>33LP^qfB$5AvHNh8k-OYj=?@#aovM`GYWKHjeekRK0l$C@>!`@98$
z&Y#@u(=+)yLx7;y{c6Xp&yi_tc9&B|Aia2w#oKk8x4b`H3}P7qmJ12
z!sZ&h-@a0<;i*JwEeuUfaJFfznvCU2(^Z@hx2@2&jl~~gQvSCdD@i~x=Fn{E%WlJD
zxO%Kdkd@8I(%gSS%Y_Q>HQAh5W7e;@3l-Wnu7#t`I{o>55(P~Zfl10Eo+9?$4c$1a
zqTsh>?!!yM8y~VV-6pW#6Tz4Nc;o7H|03#^W)LBL>MJb4SNgxf^2kNy
z^y#l&9X+uvEAzq|e^2kRyTqT2{@=J!ijZD<%Suh%s;8$`Ui-xB%`C$B7tp2)iYqQU
z;oot7@(4PiAa&l;)ZD|CTZ?;Y^8!Nbq5rb+K0)E6!Fg5#`I;Er&L&O1|5abYDOx?z`WAyS
zyE*m#_mmW(f>v1d#TPuBnQYo*1k(Xm9EdR{X?I}`wygEQ^?i&
zL7h_wx-Dly_{=iCb+^HCFPrwa^At)`3>-$%?#cu91qTy#9Ig}jV1k!R%)e{yudx21F5V0YbeI^+^V+
zLO5ixlqGngm<_J_S5NP--
z`HEa2hwFx$l#R{Ao%#r7!GYWx(oc!
zploj7*EBVWtrmL^crSTFo(R*xP1F$&9XHC8+Zq;T;-DuaQK&f-QD7NKM{wZ}kv=kr
zjQqCNv&8hM1OM+gydwN4{x5W(rxTGdBV9ks(;zki2xz{5LDqMCKcm6sQM66^UN~~V%RGfq-kIEck$lXq%zIjz>241CxZa)o2WuK1jOx?a69s-OuSaY_xP?v<&-c9r*wd7yzoGdDgs?w!DCn-0fpNIc4O&M$Y_6#BzuTsQbUBQb*a9i@wlBOeQ7q8d@s;;%ou@y0}QQ~%PJQZuZ^1gff5hgaFORydXm0p6*_lllD}!L=GqyrHVY|u^RV8>Sw+yRl{}aFW6Z+g
z=|eiJ_qS{EM7rD2|8#bAyo1z6;mc|7M*fkzq4?wD;xn~j)0q2nbZ@ULi@;0C
zOFbF`^mSB@*
z7sx-`aQRz1_4a7|o{PiwVCjQM_83OGYW%*I#@$)R$_n^P(HUqU?uNsx!zH?K1W(i#r28MG8s|auImOh
z9MZrtWDa(u`mG&Gme-E`$8Na)s{}Z`I)&1wunID#j8-Y(L+Hwq8Ly{-P1P3+c5zdP
z%PDuTG{6$;XnB_}abWd}h5t;>c%nR!TCqAR2J~-1hwY?A$ttGz?lh5xU(uL|hs
zGkfY{Dycn;+V6+^TMo#6_JnvE7`SW0#@a|%*ZCUG2Z$(8Wq_-bMdYbchydr&mKL<^6@?^n9Opt@C=~uaM27oWmsgM8pFfebdUPuQx&0
z0O2l6MEQ_S?$s>5&DEw2(G}$b-hhg!IdkpFZZj6zQaWFBmCyc8hJKN}-e#0C=8r4NZp=1#`
z6(fh&0~D@jMcW(FXcX(j>9ejAxw_dKPi|h6TBakOFdaL+P@STUmn%|mV6xcOO1@x&XkZ4?E
ztTs1JCuO4}K}yRpI_8!P;<)h{fuKm%*e#0DEQgK}ZM-8#o@s{_U9~-wQW+ZPXZMl`
z^_)kuK1ZCvI?$Vob?Tlf#RTftz9g+O@EDUWU^csGlO&*nMaoiKo@|pIqNa{CxM%9$
zTmi2TvV@+~HvgMFoOr7JXzGVv{sb}kA2AjHkrI8JoAJ&4XxK!aG9i-K2qw0Sa+=V3
zl)E}|pCfQ*Gqla=n1Zcnirh#AYFH@?eMBbu{?ff2w$gTzNKf9?>QO=9XZ!!4x=iVb
z#Zoq-MEMW?64DaZt{T%`94S;C8f+cE>s{}xdPM+yg|d*4I4r0jTPESB{4}s6OLx$V
z%kgy>N88p>o3gt_wieTWJdS#y}un|
zXdb4(!3kMeAQ69rm1vlHEz#6`W!as6UP1d^LUfE^|EVV>ir2$|Z^mP$Q{k
z!JHO$*;wThArrQc3sWpRvD2eZHYC;&7rPnz^cREre*QG>&76H@jsH!yqnirkoER?p
zREV;rvrN+RSp3z;c`u|Q1)XLxJama1p7>M4$B^Hw*&-ALQ~B
zx~A?{^@hBQLTJsn9XlDddfl_NZ9^lGYU%qXBR6XCUV(G;AEEpB3r^m0jt%?bDK>2=Bce67J@In=J}Wej8X
zw9Y@9xIjbuvsUEiTNQXzRB6!Xhgl$t$OGM$^*fBW{<&9%#Xz|FFbrS
zJE}o<$A?GusAqq7^v)iTu3N!+-zq+YPT%L|g}YVnX?9Q(g<$7>LR8&UbU^eBEUbtW
zI=&)danmxX2fBUa~QmxvkzAXQ(W
z4T&8nR}5R5cAmo&tl@Hj@dJTWH4egxM;0YW)(^t#r&Plo6Ukap2p1cG)|1~@W#x=j
z3DVN@m6c^+V7!%fKYkPkqE#j~ys=uu7#$DywnIM26v#sS=BFye$0hR<%j6Q|XkYS;
z9N_Y;RQ<5X=QIxB9Y){aMvRodMV~fVs^)XGyX8z31WH#+;YN;^JL$9-klSbo?DQlm
zu`@%_^rC~Xp8GW>Ri~{zzB|AU@*axU!uq+j=#48+uMTnHY$sFtNf#+T26(lYNsQ$z
z2hPYfWd~KCwe+iKl>LWgAzvaF(pY1qOo_Do)ow#u77E7iv6-^1lT5%n!NPk}&`6^Y
z<{sf1HMjEqIIX#K8_NvzB3vQv3OAow5yLF}j>Jt|z!
zXFJJEa$xj$HMkB(%L+Wrii{n%zKKiN82SWcR^o>hUHQ(^F^;{}=wJEhx1OCDkaLQp
zRD{XzZuve#uGI8C-f29T-_Z&Z^E~=}n=F(2Y?pi=*UL(H8EN-urfeoYpFs+xbJ7V{
zG@@?Gf(x5Jx<1YpItpUG&N(ztmi772%k#3mg3#WJZWNq~HdH0znG_eLm+@Bg@5vx+
zqZ3ii7cM|$wQK!tTUg*UJMf-7a%4jl-#9rpM*AgtYpucCzu#EbSTVZ~#BZs`babPp
zx*8|YBs1J|y9RBkdN)%zjtZ1~_e4j9PTxtr==sARKX}^dx-DDqHTwS5-&B^e0h!|TP1BChFIHiGmvko
zh*^}2_(k{UcP`v3W02wYvA;}If)t?jm?h_BZZlQHt8X@X9$Os7l&_vJQCY=5Uo_7D
zV7Eoe59Kv|73mK9!Y@ACNb$3n9)y-0;Ng&lz5^FcdEZc~y@z6%^@L`cZV-TmXPpvt
z=2$PPqhL|)vpJU2zud*)LN8L#Iw+``TN0QN1Fa|?Xl-tAaj}P=w6DKGo;~n^86y-~
zrMWv4qo{gqRA{1=Glan6>RztLPG&%d+nKzt)19JUorQKvv{KsRQUejmcU@mhcq_L
zJ=Hw&Wi6Km{AB!1vI(P>1y)^-75TPPYsDLD&*ag27B@5YJIrd^kOIX>H$&a(UphCFhhG
z^H)O)^R-yedp?$ShHmL|uuXJ-vbnii@`vzioV+1@KPIe=iQ*(&S)MZ$aFUwc(<>f@C*8
zTH&ZxRjfWk)bGJ$nb0x9T0&pwY`3|6B!sjZW^gK=@y$Mu!6!;M!-{09$Po(cFb`&;
z0Oy>bg-5mlNb7*nS$eoPMFyulzLPp$)harU)^uCNXdJMF|I)J;IjM)Zlz{wl6EAO)
zfd3XFAF(3rG3fN3|NRC?E}gWWoW!4Ff17s|2jMst_xp(5O=}jPOhD$`I+B~^S}#bF
zBG{b`>XOrK9_+77IIPE;UnqkW;+XaEi-Sbk$Flf@2-J&A%UbE_inQ4is>v(RCU32d
z-e+a!a>U!lHcEJu^AO^bSBg@DEG_!I)&4)XWt<{V{8YmRQ%^9iKaHsq28;=pR)I7C
zbEfRAI-W2Iw>}iFL}iRM&mbp^hva7DWo+>#Rn?c#v!1h?PL&OkJw1SXOA0r$@q%0zmh^vhiqVj4ln-uVuRPJT|
zXNxIJ6#p+Ve!?dUkQ!|>E%-km5D=~9gS+G*Xtc3@IxU6X>iPa41#KG{_2hujWoV1`
zbYB3xiXzP+;{Wk@&j&2C0*;6hay}wJiMA(~4A2Qs5fOn)dW-;QAn6OYtp*@1wkFb!
z5(ro)q2p2E0I8JT^KyGnfx)Zl4X)#GFuFP$tT)RxOn4%tHTJYJ;vfl(K9NQ{q2!lW
zCb6xSTHI(PkflyyXyRkX>yl$^X_f}7$0Pvq2<90OZy*gh0pl6hFCnRtV>Cx2`f78Z
zH6j2`p^--zTMdR^;|^WMo8Wb`idHo*E|tFuR3Gw+MngF=T-@K4A=G)k=d`MghGHIu
zgLkm~eHy}qh%mK$zX|HTEjumplrP5W>x-aVy2bCR&(0nay%e4PHB-n@Dk!zlMe`(T
z>=dLjzS5hZhe>cv1Zm|6T)sZ)#r5YLQ=n3bph*AG{?um~CfqMlX_nEou}BL352`9_|lc_40nf-++@BYPyF>5g}f;
z>Yyc4eQ{TUF490sJ^dxd6mCU7AuTx=1(~h<`ghvia
z%r5x4jwAbVX-znA`Xl=R23uDgEYX6()h
zaJGg*w1D+D%Snb?>3FYz6}Gf5CI(_-I`i3asGCkqZKac=jQ&g4OL9u}>OQlXvTbh%
zzb&qoD0RiQE?+<>#=SJBsLujIUhr$Gyx9*nKI-B;rW}arJWUKtVvk7#e(gf;Ox$fr_#EhbD@YeJ?O;Xm}iP%
zo$-@8d&RCaaCTgp>POWSG)c?AWf^FgVtRu%s1El>qpGrBoihFTq6u2hr{{h}+TMx?
z*3I$tCp||8sp>rNy_7f6$X}))qcSE_AXRnIb+
z`KtCm>Rq$0cd5Yr)x6Rh@6xmv2D{SQ|Geb{Max08y}wme1q?1fk+HUpgH?`wPxHTZ
zAn2aK2C*wGWy&V9-FnC-qbadPfX!&Z84Fw3GfxiuSfAGI%_IJR#rS?I26kKXhhq%({Rp-2>DKW2ldU}(
zqdVZfcj>ZGz)JdzQFJoJYHACLiI#+8P|27uCBUlH{>fp;SZ~L}UVG>g%A|DF`3%3(
zK-Z>JlDcMj^Y(-(`+aeUh+5loV$4JhyNOET(4>W*OebyP`!A%w{L37eSG--{S
z(pT4>?Ej~q82a-l+Fg3>ZrTEEpaliqa6IwEmrU|fy4|@DYJ4akA{zbG(t96}LL&+n
zcZu?kZK7ma?x0hXi899}Mx3tMx3)K(b|Kq&SmXE=7INR{xeynvf(Q|%7|o=R0v?;E
zr^e9G?*nT*LAN|`8O1y`S37Mi4w8ogGc`82jIhpAB(Rl*E#CMVYGZvT4$|R4Z~-3VwUL?uw)<)+b3F@Y#4vEEh&A~tm6e#)Ceb@j-
zPkU+(QOEk8%EMZI{0lV4X8t5EeX6|%t$gmK{sH?GzRlf)-3jPlcVq7Q7%u?`@96Ss
zeH2eDgtc@`=u$l$Eq%_82CFBwarW&s$uld}L$L{b5&RI=-+o!QOk*HCwW(BlDl4fO
z?Q}GQ-6?{dN}AUpn1K##qCueJWF8oP9g}%FB!uknayO8+$KhU>+s+uvNbARlz=?(Oriy0Po5>wD5&M96?G0e2G+3B@TS<<*iG}$
zZ$6rEh`vtTuMbro5dirrLTsr(lGaVGBtoXPQdP%nS^Phfvhlwxh}E9LFY<_
z=}Xq2o)62$-dq`IR+6V&ZzkP=+n%R$ArDf|K%kwvZ@G|WN)R7)+MBp7E-X+yD_6K1
z73iLrJPUY|v=-h0O4+_I0dA2c0a2&LD98N;pqHdYq<7K#=xLpC$Ya>)urMWGaW%E44%Gw=L$(DI-H3ypxEq-**JyumRXxrr$r6(*|
zJ4Kb8y*Ij6vBJ*3PD<%iis(lrTDoZE!+SDO*sR3ai-I`TMNMSi!fM&`PT>_?r%m
zSbOB6%j5SZzm4gNxugWr$w4)5B~5Jw%=UI?yi8rbOKT2m8^00FaJMAv@xuLBsCch-
zS@WIX3tZRU<2JVom#}9-EYgo}!cWhlU}EL_hDdp!Bj(aR+jF$d<38i$(ZyD-{Fe@>
zjqQ#qEXkTM!qg%7XGZi^?s^kSHp@MMR3!uTzRitgLM17
zNlj)`4zfJK*TY*1#x$5s4D(6)TBOr{ee8H3Q62-;ywS{@$@tM(p;vX-r4BG;N`I7n
z7Ikq`s)?4VzIal$toDX3me0r=+cNiBhd-qV`Pab)hs#
zM9rIUGwaxwB&F5`LR)^3zL$up8w7TZqVC=q%di5~<|~3YvD23mH^Y*aAhY@vt%3dg
ztd&KmPaXv^sGpBA-_5BjhFY^)J5Lc5&?FwGY!8!eA`*uCnL@{KLZn0sDdUw7``7M-
zu!6e}&^NX6xZwEv5#DrzXy
J$XR{<{{WQ~4N?FA
literal 0
HcmV?d00001
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/constants.ts
index 0c1bf051747b58..7e95bf8fb8e45a 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/constants.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/constants.ts
@@ -9,5 +9,5 @@ import { i18n } from '@kbn/i18n';
export const CRAWLER_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.crawler.title',
- { defaultMessage: 'Crawler' }
+ { defaultMessage: 'Web Crawler' }
);
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.scss
new file mode 100644
index 00000000000000..3ace4064008b6d
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.scss
@@ -0,0 +1,13 @@
+.crawlerLanding {
+ &__panel {
+ overflow: hidden;
+ background-image: url('./assets/bg_crawler_landing.png');
+ background-size: 45%;
+ background-repeat: no-repeat;
+ background-position: right -2rem;
+ }
+
+ &__wrapper {
+ max-width: 50rem;
+ }
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx
new file mode 100644
index 00000000000000..9591b82773b9fe
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.test.tsx
@@ -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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { setMockValues } from '../../../__mocks__';
+import { mockEngineValues } from '../../__mocks__';
+
+import React from 'react';
+
+import { shallow, ShallowWrapper } from 'enzyme';
+
+import { docLinks } from '../../../shared/doc_links';
+
+import { CrawlerLanding } from './crawler_landing';
+
+describe('CrawlerLanding', () => {
+ let wrapper: ShallowWrapper;
+
+ beforeEach(() => {
+ setMockValues({ ...mockEngineValues });
+ wrapper = shallow();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('contains an external documentation link', () => {
+ const externalDocumentationLink = wrapper.find('[data-test-subj="CrawlerDocumentationLink"]');
+
+ expect(externalDocumentationLink.prop('href')).toBe(
+ `${docLinks.appSearchBase}/web-crawler.html`
+ );
+ });
+
+ it('contains a link to standalone App Search', () => {
+ const externalDocumentationLink = wrapper.find('[data-test-subj="CrawlerStandaloneLink"]');
+
+ expect(externalDocumentationLink.prop('href')).toBe('/as/engines/some-engine/crawler');
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx
new file mode 100644
index 00000000000000..a2993b4d86d5a1
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_landing.tsx
@@ -0,0 +1,85 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+
+import {
+ EuiButton,
+ EuiLink,
+ EuiPageHeader,
+ EuiPanel,
+ EuiSpacer,
+ EuiText,
+ EuiTitle,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+import { getAppSearchUrl } from '../../../shared/enterprise_search_url';
+import { DOCS_PREFIX, ENGINE_CRAWLER_PATH } from '../../routes';
+import { generateEnginePath } from '../engine';
+
+import './crawler_landing.scss';
+import { CRAWLER_TITLE } from '.';
+
+export const CrawlerLanding: React.FC = () => (
+
+
+
+
+
+
+
+ {i18n.translate('xpack.enterpriseSearch.appSearch.engine.crawler.landingPage.title', {
+ defaultMessage: 'Setup the Web Crawler',
+ })}
+
+
+
+
+
+ {i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engine.crawler.landingPage.description',
+ {
+ defaultMessage:
+ "Easily index your website's content. To get started, enter your domain name, provide optional entry points and crawl rules, and we will handle the rest.",
+ }
+ )}{' '}
+
+ {i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engine.crawler.landingPage.documentationLinkLabel',
+ {
+ defaultMessage: 'Learn more about the web crawler.',
+ }
+ )}
+
+
+
+
+
+ {i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engine.crawler.landingPage.standaloneLinkLabel',
+ {
+ defaultMessage: 'Configure the web crawler',
+ }
+ )}
+
+
+
+
+
+);
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx
new file mode 100644
index 00000000000000..6aa9ca8c4feb1d
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.test.tsx
@@ -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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { setMockValues } from '../../../__mocks__';
+
+import { mockEngineValues } from '../../__mocks__';
+
+import React from 'react';
+import { Switch } from 'react-router-dom';
+
+import { shallow } from 'enzyme';
+
+import { CrawlerLanding } from './crawler_landing';
+import { CrawlerRouter } from './crawler_router';
+
+describe('CrawlerRouter', () => {
+ beforeEach(() => {
+ setMockValues({ ...mockEngineValues });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders a landing page', () => {
+ const wrapper = shallow();
+
+ expect(wrapper.find(Switch)).toHaveLength(1);
+ expect(wrapper.find(CrawlerLanding)).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx
new file mode 100644
index 00000000000000..fcc949de7d8b4b
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { Route, Switch } from 'react-router-dom';
+
+import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
+
+import { getEngineBreadcrumbs } from '../engine';
+
+import { CRAWLER_TITLE } from './constants';
+import { CrawlerLanding } from './crawler_landing';
+
+export const CrawlerRouter: React.FC = () => {
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/index.ts
index edb7e43aee35e6..58fb0a7cebb1a7 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/index.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/index.ts
@@ -6,3 +6,4 @@
*/
export { CRAWLER_TITLE } from './constants';
+export { CrawlerRouter } from './crawler_router';
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx
index 4738209cee4a23..0edf01bada9381 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx
@@ -12,7 +12,6 @@ import { useValues } from 'kea';
import { EuiText, EuiBadge, EuiIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
-import { getAppSearchUrl } from '../../../shared/enterprise_search_url';
import { SideNavLink, SideNavItem } from '../../../shared/layout';
import { AppLogic } from '../../app_logic';
import {
@@ -170,8 +169,7 @@ export const EngineNav: React.FC = () => {
)}
{canViewEngineCrawler && !isMetaEngine && (
{CRAWLER_TITLE}
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx
index 39055e772bcf93..3eab209d706fad 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx
@@ -18,6 +18,7 @@ import { shallow } from 'enzyme';
import { Loading } from '../../../shared/loading';
import { AnalyticsRouter } from '../analytics';
import { ApiLogs } from '../api_logs';
+import { CrawlerRouter } from '../crawler';
import { CurationsRouter } from '../curations';
import { Documents, DocumentDetail } from '../documents';
import { EngineOverview } from '../engine_overview';
@@ -168,4 +169,11 @@ describe('EngineRouter', () => {
expect(wrapper.find(SourceEngines)).toHaveLength(1);
});
+
+ it('renders a crawler view', () => {
+ setMockValues({ ...values, myRole: { canViewEngineCrawler: true } });
+ const wrapper = shallow();
+
+ expect(wrapper.find(CrawlerRouter)).toHaveLength(1);
+ });
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx
index 387f8cf1b9837f..40cc2ef0368c05 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx
@@ -23,7 +23,7 @@ import {
ENGINE_DOCUMENTS_PATH,
ENGINE_DOCUMENT_DETAIL_PATH,
ENGINE_SCHEMA_PATH,
- // ENGINE_CRAWLER_PATH,
+ ENGINE_CRAWLER_PATH,
META_ENGINE_SOURCE_ENGINES_PATH,
ENGINE_RELEVANCE_TUNING_PATH,
ENGINE_SYNONYMS_PATH,
@@ -34,6 +34,7 @@ import {
} from '../../routes';
import { AnalyticsRouter } from '../analytics';
import { ApiLogs } from '../api_logs';
+import { CrawlerRouter } from '../crawler';
import { CurationsRouter } from '../curations';
import { DocumentDetail, Documents } from '../documents';
import { EngineOverview } from '../engine_overview';
@@ -52,7 +53,7 @@ export const EngineRouter: React.FC = () => {
canViewEngineAnalytics,
canViewEngineDocuments,
canViewEngineSchema,
- // canViewEngineCrawler,
+ canViewEngineCrawler,
canViewMetaEngineSourceEngines,
canManageEngineRelevanceTuning,
canManageEngineSynonyms,
@@ -143,6 +144,11 @@ export const EngineRouter: React.FC = () => {
)}
+ {canViewEngineCrawler && (
+
+
+
+ )}
From 2dfda12af14f564ad42acb1a57af726527fadc61 Mon Sep 17 00:00:00 2001
From: Bhavya RM
Date: Wed, 2 Jun 2021 12:03:19 -0400
Subject: [PATCH 15/77] Unskip advanced settings a11y test (#100558)
---
x-pack/test/accessibility/apps/advanced_settings.ts | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/x-pack/test/accessibility/apps/advanced_settings.ts b/x-pack/test/accessibility/apps/advanced_settings.ts
index 7382577c7ebe6d..6f2dc78a7b35b8 100644
--- a/x-pack/test/accessibility/apps/advanced_settings.ts
+++ b/x-pack/test/accessibility/apps/advanced_settings.ts
@@ -11,45 +11,51 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['common', 'settings', 'header']);
const a11y = getService('a11y');
const testSubjects = getService('testSubjects');
+ const toasts = getService('toasts');
- // FLAKY: https://github.com/elastic/kibana/issues/99006
- describe.skip('Stack Management -Advanced Settings', () => {
+ describe('Stack Management -Advanced Settings', () => {
// click on Management > Advanced settings
it('click on advanced settings ', async () => {
await PageObjects.common.navigateToUrl('management', 'kibana/settings', {
shouldUseHashForSubUrl: false,
});
await testSubjects.click('settings');
+ await toasts.dismissAllToasts();
await a11y.testAppSnapshot();
});
// clicking on the top search bar
it('adv settings - search ', async () => {
await testSubjects.click('settingsSearchBar');
+ await toasts.dismissAllToasts();
await a11y.testAppSnapshot();
});
// clicking on the category dropdown
it('adv settings - category -dropdown ', async () => {
await testSubjects.click('settingsSearchBar');
+ await toasts.dismissAllToasts();
await a11y.testAppSnapshot();
});
// clicking on the toggle button
it('adv settings - toggle ', async () => {
await testSubjects.click('advancedSetting-editField-csv:quoteValues');
+ await toasts.dismissAllToasts();
await a11y.testAppSnapshot();
});
// clicking on editor panel
it('adv settings - edit ', async () => {
await testSubjects.click('advancedSetting-editField-csv:separator');
+ await toasts.dismissAllToasts();
await a11y.testAppSnapshot();
});
// clicking on save button
it('adv settings - save', async () => {
await testSubjects.click('advancedSetting-saveButton');
+ await toasts.dismissAllToasts();
await a11y.testAppSnapshot();
});
});
From b5b663e925bbfc023f8d7d06a9ab049a341ab0be Mon Sep 17 00:00:00 2001
From: Zacqary Adam Xeper
Date: Wed, 2 Jun 2021 11:54:18 -0500
Subject: [PATCH 16/77] [Fleet] Add support for meta in fields.yml (#100931)
* [Fleet] Add support for meta in fields.yml
* Revert formatting changes to install.ts
* Add mapping tests
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../elasticsearch/template/template.test.ts | 90 +++++++++++++++++++
.../epm/elasticsearch/template/template.ts | 18 ++++
.../fleet/server/services/epm/fields/field.ts | 4 +
3 files changed, 112 insertions(+)
diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts
index dcc685bb270b46..ae7bff618dba2a 100644
--- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts
+++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts
@@ -611,6 +611,96 @@ describe('EPM template', () => {
expect(JSON.stringify(mappings)).toEqual(JSON.stringify(constantKeywordMapping));
});
+ it('processes meta fields', () => {
+ const metaFieldLiteralYaml = `
+- name: fieldWithMetas
+ type: integer
+ unit: byte
+ metric_type: gauge
+ `;
+ const metaFieldMapping = {
+ properties: {
+ fieldWithMetas: {
+ type: 'long',
+ meta: {
+ metric_type: 'gauge',
+ unit: 'byte',
+ },
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(metaFieldLiteralYaml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(JSON.stringify(mappings)).toEqual(JSON.stringify(metaFieldMapping));
+ });
+
+ it('processes meta fields with only one meta value', () => {
+ const metaFieldLiteralYaml = `
+- name: fieldWithMetas
+ type: integer
+ metric_type: gauge
+ `;
+ const metaFieldMapping = {
+ properties: {
+ fieldWithMetas: {
+ type: 'long',
+ meta: {
+ metric_type: 'gauge',
+ },
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(metaFieldLiteralYaml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(JSON.stringify(mappings)).toEqual(JSON.stringify(metaFieldMapping));
+ });
+
+ it('processes grouped meta fields', () => {
+ const metaFieldLiteralYaml = `
+- name: groupWithMetas
+ type: group
+ unit: byte
+ metric_type: gauge
+ fields:
+ - name: fieldA
+ type: integer
+ unit: byte
+ metric_type: gauge
+ - name: fieldB
+ type: integer
+ unit: byte
+ metric_type: gauge
+ `;
+ const metaFieldMapping = {
+ properties: {
+ groupWithMetas: {
+ properties: {
+ fieldA: {
+ type: 'long',
+ meta: {
+ metric_type: 'gauge',
+ unit: 'byte',
+ },
+ },
+ fieldB: {
+ type: 'long',
+ meta: {
+ metric_type: 'gauge',
+ unit: 'byte',
+ },
+ },
+ },
+ },
+ },
+ };
+ const fields: Field[] = safeLoad(metaFieldLiteralYaml);
+ const processedFields = processFields(fields);
+ const mappings = generateMappings(processedFields);
+ expect(JSON.stringify(mappings)).toEqual(JSON.stringify(metaFieldMapping));
+ });
+
it('tests priority and index pattern for data stream without dataset_is_prefix', () => {
const dataStreamDatasetIsPrefixUnset = {
type: 'metrics',
diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts
index f6ca1dfc99f4e0..64261226a79441 100644
--- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts
+++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts
@@ -42,6 +42,8 @@ const DATASET_IS_PREFIX_TEMPLATE_PRIORITY = 150;
const QUERY_DEFAULT_FIELD_TYPES = ['keyword', 'text'];
const QUERY_DEFAULT_FIELD_LIMIT = 1024;
+const META_PROP_KEYS = ['metric_type', 'unit'];
+
/**
* getTemplate retrieves the default template but overwrites the index pattern with the given value.
*
@@ -162,6 +164,22 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings {
default:
fieldProps.type = type;
}
+
+ const fieldHasMetaProps = META_PROP_KEYS.some((key) => key in field);
+ if (fieldHasMetaProps) {
+ switch (type) {
+ case 'group':
+ case 'group-nested':
+ break;
+ default: {
+ const meta = {};
+ if ('metric_type' in field) Reflect.set(meta, 'metric_type', field.metric_type);
+ if ('unit' in field) Reflect.set(meta, 'unit', field.unit);
+ fieldProps.meta = meta;
+ }
+ }
+ }
+
props[field.name] = fieldProps;
});
}
diff --git a/x-pack/plugins/fleet/server/services/epm/fields/field.ts b/x-pack/plugins/fleet/server/services/epm/fields/field.ts
index addcaf20cd146f..b8839b88bb78c7 100644
--- a/x-pack/plugins/fleet/server/services/epm/fields/field.ts
+++ b/x-pack/plugins/fleet/server/services/epm/fields/field.ts
@@ -34,6 +34,10 @@ export interface Field {
include_in_parent?: boolean;
include_in_root?: boolean;
+ // Meta fields
+ metric_type?: string;
+ unit?: string;
+
// Kibana specific
analyzed?: boolean;
count?: number;
From d87e30e8c385eae4f599367db64f06a6b5172b34 Mon Sep 17 00:00:00 2001
From: DeDe Morton
Date: Wed, 2 Jun 2021 10:02:26 -0700
Subject: [PATCH 17/77] Edit text strings in Heartbeat setup prompt (#100753)
* Edit text strings in Heartbeat setup prompt
* Update snapshot to fix test failure
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../__snapshots__/data_or_index_missing.test.tsx.snap | 4 ++--
.../overview/empty_state/data_or_index_missing.tsx | 6 +++---
.../public/components/overview/empty_state/empty_state.tsx | 2 +-
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap
index 41e46259715ee0..45e40f71c0fdef 100644
--- a/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap
+++ b/x-pack/plugins/uptime/public/components/overview/empty_state/__snapshots__/data_or_index_missing.test.tsx.snap
@@ -51,14 +51,14 @@ exports[`DataOrIndexMissing component renders headingMessage 1`] = `
diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx
index 77927b5750ff3d..7f9839ff94dbe8 100644
--- a/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx
+++ b/x-pack/plugins/uptime/public/components/overview/empty_state/data_or_index_missing.tsx
@@ -43,14 +43,14 @@ export const DataOrIndexMissing = ({ headingMessage, settings }: DataMissingProp
>
diff --git a/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx b/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx
index 5a28c7c2592d73..a6fd6579c49fa2 100644
--- a/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx
+++ b/x-pack/plugins/uptime/public/components/overview/empty_state/empty_state.tsx
@@ -38,7 +38,7 @@ export const EmptyStateComponent = ({
const noIndicesMessage = (
{settings?.heartbeatIndices} }}
/>
);
From e607b5859092317a9aa5a04a4f37c13dec53f0fa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?=
Date: Wed, 2 Jun 2021 13:08:09 -0400
Subject: [PATCH 18/77] Fix alerting health API to consider rules in all spaces
(#100879)
* Initial commit
* Expand tests
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../alerting/server/health/get_health.ts | 4 +
.../alerting_api_integration/common/config.ts | 1 +
.../tests/alerting/health.ts | 128 ++++++++++++++++++
.../tests/alerting/index.ts | 1 +
4 files changed, 134 insertions(+)
create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts
diff --git a/x-pack/plugins/alerting/server/health/get_health.ts b/x-pack/plugins/alerting/server/health/get_health.ts
index 4a0266c9b729f1..6966c9b75ca434 100644
--- a/x-pack/plugins/alerting/server/health/get_health.ts
+++ b/x-pack/plugins/alerting/server/health/get_health.ts
@@ -34,6 +34,7 @@ export const getHealth = async (
sortOrder: 'desc',
page: 1,
perPage: 1,
+ namespaces: ['*'],
});
if (decryptErrorData.length > 0) {
@@ -51,6 +52,7 @@ export const getHealth = async (
sortOrder: 'desc',
page: 1,
perPage: 1,
+ namespaces: ['*'],
});
if (executeErrorData.length > 0) {
@@ -68,6 +70,7 @@ export const getHealth = async (
sortOrder: 'desc',
page: 1,
perPage: 1,
+ namespaces: ['*'],
});
if (readErrorData.length > 0) {
@@ -83,6 +86,7 @@ export const getHealth = async (
type: 'alert',
sortField: 'executionStatus.lastExecutionDate',
sortOrder: 'desc',
+ namespaces: ['*'],
});
const lastExecutionDate =
noErrorData.length > 0
diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts
index c56e8adfbe34fb..548b4d0db1124f 100644
--- a/x-pack/test/alerting_api_integration/common/config.ts
+++ b/x-pack/test/alerting_api_integration/common/config.ts
@@ -151,6 +151,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
`--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`,
'--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
'--xpack.alerting.invalidateApiKeysTask.interval="15s"',
+ '--xpack.alerting.healthCheck.interval="1s"',
`--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`,
`--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`,
`--xpack.actions.tls.verificationMode=${verificationMode}`,
diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts
new file mode 100644
index 00000000000000..668de3eb4fb9e6
--- /dev/null
+++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/health.ts
@@ -0,0 +1,128 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import expect from '@kbn/expect';
+import { UserAtSpaceScenarios } from '../../scenarios';
+import { FtrProviderContext } from '../../../common/ftr_provider_context';
+import {
+ getUrlPrefix,
+ getTestAlertData,
+ ObjectRemover,
+ AlertUtils,
+ ESTestIndexTool,
+ ES_TEST_INDEX_NAME,
+} from '../../../common/lib';
+
+// eslint-disable-next-line import/no-default-export
+export default function createFindTests({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+ const es = getService('legacyEs');
+ const retry = getService('retry');
+ const supertestWithoutAuth = getService('supertestWithoutAuth');
+ const esTestIndexTool = new ESTestIndexTool(es, retry);
+
+ describe('health', () => {
+ const objectRemover = new ObjectRemover(supertest);
+
+ before(async () => {
+ await esTestIndexTool.destroy();
+ await esTestIndexTool.setup();
+ });
+
+ after(async () => {
+ await esTestIndexTool.destroy();
+ });
+
+ for (const scenario of UserAtSpaceScenarios) {
+ const { user, space } = scenario;
+
+ describe(scenario.id, () => {
+ let alertUtils: AlertUtils;
+ let indexRecordActionId: string;
+
+ before(async () => {
+ const { body: createdAction } = await supertest
+ .post(`${getUrlPrefix(space.id)}/api/actions/connector`)
+ .set('kbn-xsrf', 'foo')
+ .send({
+ name: 'My action',
+ connector_type_id: 'test.index-record',
+ config: {
+ unencrypted: `This value shouldn't get encrypted`,
+ },
+ secrets: {
+ encrypted: 'This value should be encrypted',
+ },
+ })
+ .expect(200);
+ indexRecordActionId = createdAction.id;
+ objectRemover.add(space.id, indexRecordActionId, 'connector', 'actions');
+
+ alertUtils = new AlertUtils({
+ user,
+ space,
+ supertestWithoutAuth,
+ indexRecordActionId,
+ objectRemover,
+ });
+ });
+
+ after(() => objectRemover.removeAll());
+
+ it('should return healthy status by default', async () => {
+ const { body: health } = await supertestWithoutAuth
+ .get(`${getUrlPrefix(space.id)}/api/alerting/_health`)
+ .auth(user.username, user.password);
+ expect(health.is_sufficiently_secure).to.eql(true);
+ expect(health.has_permanent_encryption_key).to.eql(true);
+ expect(health.alerting_framework_heath.decryption_health.status).to.eql('ok');
+ expect(health.alerting_framework_heath.execution_health.status).to.eql('ok');
+ expect(health.alerting_framework_heath.read_health.status).to.eql('ok');
+ });
+
+ it('should return error when a rule in the default space is failing', async () => {
+ const reference = alertUtils.generateReference();
+ const { body: createdRule } = await supertest
+ .post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
+ .set('kbn-xsrf', 'foo')
+ .send(
+ getTestAlertData({
+ schedule: {
+ interval: '5m',
+ },
+ rule_type_id: 'test.failing',
+ params: {
+ index: ES_TEST_INDEX_NAME,
+ reference,
+ },
+ })
+ )
+ .expect(200);
+ objectRemover.add(space.id, createdRule.id, 'rule', 'alerting');
+
+ const ruleInErrorStatus = await retry.tryForTime(30000, async () => {
+ const { body: rule } = await supertest
+ .get(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`)
+ .expect(200);
+ expect(rule.execution_status.status).to.eql('error');
+ return rule;
+ });
+
+ await retry.tryForTime(30000, async () => {
+ const { body: health } = await supertestWithoutAuth
+ .get(`${getUrlPrefix(space.id)}/api/alerting/_health`)
+ .auth(user.username, user.password);
+ expect(health.alerting_framework_heath.execution_health.status).to.eql('warn');
+ expect(health.alerting_framework_heath.execution_health.timestamp).to.eql(
+ ruleInErrorStatus.execution_status.last_execution_date
+ );
+ });
+ });
+ });
+ }
+ });
+}
diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts
index b1b52d89997cd3..6ca68bd1881245 100644
--- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts
+++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts
@@ -51,6 +51,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./alerts'));
loadTestFile(require.resolve('./event_log'));
loadTestFile(require.resolve('./mustache_templates'));
+ loadTestFile(require.resolve('./health'));
});
});
}
From 66553681c085e7a31d11a6f96139acaffa5d0e24 Mon Sep 17 00:00:00 2001
From: Nicolas Chaulet
Date: Wed, 2 Jun 2021 13:44:04 -0400
Subject: [PATCH 19/77] [Fleet] Fix host input with empty value (#101178)
---
.../components/settings_flyout/hosts_input.test.tsx | 9 +++++++++
.../fleet/components/settings_flyout/hosts_input.tsx | 6 +++++-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx
index 27bf5af72fb61d..f441cfd951ba9a 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx
@@ -66,3 +66,12 @@ test('it should allow to update existing host with multiple hosts', async () =>
fireEvent.change(inputEl, { target: { value: 'http://newhost.com' } });
expect(mockOnChange).toHaveBeenCalledWith(['http://newhost.com', 'http://host2.com']);
});
+
+test('it should render an input if there is not hosts', async () => {
+ const { utils, mockOnChange } = renderInput([]);
+
+ const inputEl = await utils.findByDisplayValue('');
+ expect(inputEl).toBeDefined();
+ fireEvent.change(inputEl, { target: { value: 'http://newhost.com' } });
+ expect(mockOnChange).toHaveBeenCalledWith(['http://newhost.com']);
+});
diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx
index 0e5f9a5e028b5e..6c87a983f58a4a 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx
@@ -132,7 +132,7 @@ const SortableTextField: FunctionComponent = React.memo(
export const HostsInput: FunctionComponent = ({
id,
- value,
+ value: valueFromProps,
onChange,
helpText,
label,
@@ -140,6 +140,10 @@ export const HostsInput: FunctionComponent = ({
errors,
}) => {
const [autoFocus, setAutoFocus] = useState(false);
+ const value = useMemo(() => {
+ return valueFromProps.length ? valueFromProps : [''];
+ }, [valueFromProps]);
+
const rows = useMemo(
() =>
value.map((host, idx) => ({
From dc5511f73bfb631b50e4ddf4ebe6a6b8c6bbdfc8 Mon Sep 17 00:00:00 2001
From: Tre
Date: Wed, 2 Jun 2021 12:28:59 -0600
Subject: [PATCH 20/77] [QA] Bind the retry to fixup error in it repo tests
(#100948)
Verfiied via: https://internal-ci.elastic.co/view/All/job/elastic+integration-test+master/487/
---
.../apps/reporting/reporting_watcher.js | 2 +-
.../apps/reporting/reporting_watcher_png.js | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js
index 31eb6d65ce7acb..fb881162f51e81 100644
--- a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js
+++ b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher.js
@@ -85,7 +85,7 @@ export default function ({ getService, getPageObjects }) {
await putWatcher(watch, id, body, client, log);
});
it('should be successful and increment revision', async () => {
- await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime);
+ await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime.bind(retry));
});
it('should delete watch and update revision', async () => {
await deleteWatcher(watch, id, client, log);
diff --git a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js
index 7e9ced57fdc0b9..db913f563ebb00 100644
--- a/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js
+++ b/x-pack/test/stack_functional_integration/apps/reporting/reporting_watcher_png.js
@@ -79,7 +79,7 @@ export default ({ getService, getPageObjects }) => {
await putWatcher(watch, id, body, client, log);
});
it('should be successful and increment revision', async () => {
- await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime);
+ await getWatcher(watch, id, client, log, PageObjects.common, retry.tryForTime.bind(retry));
});
it('should delete watch and update revision', async () => {
await deleteWatcher(watch, id, client, log);
From 6724a474dee0d2590996200c99ba2bffcae09560 Mon Sep 17 00:00:00 2001
From: Jen Huang
Date: Wed, 2 Jun 2021 11:39:18 -0700
Subject: [PATCH 21/77] Convert $json to json in package README code blocks
(#101187)
---
.../epm/screens/detail/overview/markdown_renderers.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx
index 47c327b17c2414..cbc2f7b5f78881 100644
--- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx
+++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/overview/markdown_renderers.tsx
@@ -60,8 +60,10 @@ export const markdownRenderers = {
),
code: ({ language, value }: { language: string; value: string }) => {
+ // Old packages are using `$json`, which is not valid any more with the move to prism.js
+ const parsedLang = language === '$json' ? 'json' : language;
return (
-
+
{value}
);
From dbac313d406f4ae44351b134859fb7ecdd8962bd Mon Sep 17 00:00:00 2001
From: gchaps <33642766+gchaps@users.noreply.github.com>
Date: Wed, 2 Jun 2021 11:42:26 -0700
Subject: [PATCH 22/77] [DOCS] Updates homebrew content to use latest version
(#101199)
---
docs/setup/install/brew.asciidoc | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/docs/setup/install/brew.asciidoc b/docs/setup/install/brew.asciidoc
index e3085f6e225aa2..eeba869a259d43 100644
--- a/docs/setup/install/brew.asciidoc
+++ b/docs/setup/install/brew.asciidoc
@@ -14,15 +14,13 @@ brew tap elastic/tap
-------------------------
Once you've tapped the Elastic Homebrew repo, you can use `brew install` to
-install the default distribution of {kib}:
+install the **latest version** of {kib}:
[source,sh]
-------------------------
brew install elastic/tap/kibana-full
-------------------------
-This installs the most recently released distribution of {kib}.
-
[[brew-layout]]
==== Directory layout for Homebrew installs
From 8e48d48f86633011b3e7eb411e8cfe672fa54c08 Mon Sep 17 00:00:00 2001
From: Tiago Costa
Date: Wed, 2 Jun 2021 20:20:26 +0100
Subject: [PATCH 23/77] docs(NA): update developer getting started guide to
build on windows within Bazel (#101181)
---
docs/developer/getting-started/index.asciidoc | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc
index ac8eff132fcfe8..a28a95605bc6ab 100644
--- a/docs/developer/getting-started/index.asciidoc
+++ b/docs/developer/getting-started/index.asciidoc
@@ -14,9 +14,14 @@ In order to support Windows development we currently require you to use one of t
As well as installing https://www.microsoft.com/en-us/download/details.aspx?id=48145[Visual C++ Redistributable for Visual Studio 2015].
+In addition we also require you to do the following:
+
+- Install https://www.microsoft.com/en-us/download/details.aspx?id=48145[Visual C++ Redistributable for Visual Studio 2015]
+- Enable the https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development[Windows Developer Mode]
+- Enable https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/fsutil-8dot3name[8.3 filename support] by running the following command in a windows command prompt with admin rights `fsutil 8dot3name set 0`
+
Before running the steps listed below, please make sure you have installed everything
-that we require and listed above and that you are running the mentioned commands
-through Git bash or WSL.
+that we require and listed above and that you are running all the commands from now on through Git bash or WSL.
[discrete]
[[get-kibana-code]]
From 98527ad232a823ae72731062435f707d79644732 Mon Sep 17 00:00:00 2001
From: spalger
Date: Wed, 2 Jun 2021 14:14:54 -0700
Subject: [PATCH 24/77] skip suite failing es promotion (#101219)
---
.../apis/management/index_lifecycle_management/policies.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js
index 55b4da8b11ec25..8f40f5826c537c 100644
--- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js
+++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js
@@ -32,7 +32,8 @@ export default function ({ getService }) {
const { addPolicyToIndex } = registerIndexHelpers({ supertest });
- describe('policies', () => {
+ // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/101219
+ describe.skip('policies', () => {
after(() => Promise.all([cleanUpEsResources(), cleanUpPolicies()]));
describe('list', () => {
From 71b4c38c4a579b0b4871b9f437d6d855f5076511 Mon Sep 17 00:00:00 2001
From: Steph Milovic
Date: Wed, 2 Jun 2021 17:37:11 -0600
Subject: [PATCH 25/77] [Security Solution] [Bug Fix] Fix flakey cypress tests
(#101231)
---
.../cypress/support/commands.js | 57 ++-----------------
1 file changed, 6 insertions(+), 51 deletions(-)
diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js
index d9de00be0ea9ec..90eb9a38d7509c 100644
--- a/x-pack/plugins/security_solution/cypress/support/commands.js
+++ b/x-pack/plugins/security_solution/cypress/support/commands.js
@@ -31,62 +31,17 @@
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
-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 (stubObject, factoryQueryType, searchStrategyName = 'securitySolutionSearchStrategy') {
cy.intercept('POST', '/internal/bsearch', (req) => {
- const findRequestConfig = getFindRequestConfig(searchStrategyName, factoryQueryType);
- const requestIndex = findIndex(findRequestConfig, req.body.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);
- });
+ if (searchStrategyName === 'securitySolutionIndexFields') {
+ req.reply(stubObject.rawResponse);
+ } else if (factoryQueryType === 'overviewHost') {
+ req.reply(stubObject.overviewHost);
+ } else if (factoryQueryType === 'overviewNetwork') {
+ req.reply(stubObject.overviewNetwork);
}
-
req.reply();
});
}
From 45ae6cc39b09e6ee4132ed32f74df290e532ed40 Mon Sep 17 00:00:00 2001
From: Yuliia Naumenko
Date: Wed, 2 Jun 2021 22:33:43 -0700
Subject: [PATCH 26/77] [Alerting UI] Reduced triggersActionsUi bundle size by
making all action types UI validation messages translations asynchronous.
(#100525)
* [Alerting UI] Reduced triggersActionsUi bundle size by making all connectors validation messages translations asyncronus.
* changed validation logic to be async
* fixed action form
* fixed tests
* fixed tests
* fixed validation usage in security
* fixed due to comments
* fixed due to comments
* added spinner for the validation awaiting
* fixed typechecks
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
---
.../components/connectors/case/index.ts | 4 +-
.../public/alerts/alert_form.test.tsx | 8 +-
.../rules/step_rule_actions/index.tsx | 1 +
.../rules/step_rule_actions/schema.test.tsx | 24 ++---
.../rules/step_rule_actions/schema.tsx | 28 ++---
.../rules/step_rule_actions/utils.test.ts | 16 +--
.../rules/step_rule_actions/utils.ts | 6 +-
x-pack/plugins/triggers_actions_ui/README.md | 32 +++---
.../builtin_action_types/email/email.test.tsx | 28 ++---
.../builtin_action_types/email/email.tsx | 100 ++++--------------
.../email/email_connector.tsx | 31 ++++--
.../email/email_params.tsx | 25 +++--
.../email/translations.ts | 78 ++++++++++++++
.../es_index/es_index.test.tsx | 24 ++---
.../es_index/es_index.tsx | 37 ++-----
.../es_index/es_index_connector.tsx | 6 +-
.../es_index/es_index_params.tsx | 6 +-
.../es_index/translations.ts | 29 +++++
.../builtin_action_types/jira/jira.test.tsx | 20 ++--
.../builtin_action_types/jira/jira.tsx | 46 +++++---
.../jira/jira_connectors.tsx | 12 ++-
.../builtin_action_types/jira/jira_params.tsx | 2 +
.../builtin_action_types/jira/translations.ts | 14 ---
.../pagerduty/pagerduty.test.tsx | 14 +--
.../pagerduty/pagerduty.tsx | 39 ++-----
.../pagerduty/pagerduty_connectors.tsx | 7 +-
.../pagerduty/pagerduty_params.tsx | 14 ++-
.../pagerduty/translations.ts | 29 +++++
.../resilient/resilient.test.tsx | 16 +--
.../resilient/resilient.tsx | 47 +++++---
.../resilient/resilient_connectors.tsx | 13 ++-
.../resilient/resilient_params.tsx | 6 +-
.../resilient/translations.ts | 14 ---
.../server_log/server_log.test.tsx | 12 +--
.../server_log/server_log.tsx | 8 +-
.../servicenow/servicenow.test.tsx | 16 +--
.../servicenow/servicenow.tsx | 67 +++++++++---
.../servicenow/servicenow_connectors.tsx | 9 +-
.../servicenow/servicenow_itsm_params.tsx | 1 +
.../servicenow/servicenow_sir_params.tsx | 1 +
.../servicenow/translations.ts | 28 -----
.../builtin_action_types/slack/slack.test.tsx | 24 ++---
.../builtin_action_types/slack/slack.tsx | 46 ++------
.../slack/slack_connectors.tsx | 6 +-
.../slack/slack_params.tsx | 2 +-
.../slack/translations.ts | 36 +++++++
.../builtin_action_types/teams/teams.test.tsx | 24 ++---
.../builtin_action_types/teams/teams.tsx | 46 ++------
.../teams/teams_connectors.tsx | 7 +-
.../teams/teams_params.tsx | 2 +-
.../teams/translations.ts | 36 +++++++
.../webhook/translations.ts | 64 +++++++++++
.../webhook/webhook.test.tsx | 24 ++---
.../builtin_action_types/webhook/webhook.tsx | 85 +++------------
.../webhook/webhook_connectors.tsx | 25 +++--
.../action_connector_form.test.tsx | 8 +-
.../action_connector_form.tsx | 11 +-
.../action_form.test.tsx | 40 +++----
.../action_connector_form/action_form.tsx | 81 +++++++-------
.../action_type_form.test.tsx | 17 ++-
.../action_type_form.tsx | 17 ++-
.../action_type_menu.test.tsx | 24 ++---
.../connector_add_flyout.test.tsx | 8 +-
.../connector_add_flyout.tsx | 76 +++++++++----
.../connector_add_modal.test.tsx | 8 +-
.../connector_add_modal.tsx | 79 ++++++++++----
.../connector_edit_flyout.test.tsx | 16 +--
.../connector_edit_flyout.tsx | 97 ++++++++++-------
.../test_connector_form.test.tsx | 8 +-
.../test_connector_form.tsx | 13 ++-
.../actions_connectors_list.test.tsx | 8 +-
.../sections/alert_form/alert_add.test.tsx | 8 +-
.../sections/alert_form/alert_add.tsx | 21 +++-
.../sections/alert_form/alert_add_footer.tsx | 18 +++-
.../sections/alert_form/alert_edit.test.tsx | 8 +-
.../sections/alert_form/alert_edit.tsx | 35 ++++--
.../sections/alert_form/alert_form.test.tsx | 10 +-
.../sections/alert_form/alert_form.tsx | 24 +++--
.../public/application/type_registry.test.ts | 8 +-
.../triggers_actions_ui/public/types.ts | 4 +-
80 files changed, 1149 insertions(+), 843 deletions(-)
create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts
create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts
create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts
create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts
create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts
create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts
diff --git a/x-pack/plugins/cases/public/components/connectors/case/index.ts b/x-pack/plugins/cases/public/components/connectors/case/index.ts
index c2cf4980da7ec6..8e6680cd653875 100644
--- a/x-pack/plugins/cases/public/components/connectors/case/index.ts
+++ b/x-pack/plugins/cases/public/components/connectors/case/index.ts
@@ -25,7 +25,7 @@ const validateParams = (actionParams: CaseActionParams) => {
validationResult.errors.caseId.push(i18n.CASE_CONNECTOR_CASE_REQUIRED);
}
- return validationResult;
+ return Promise.resolve(validationResult);
};
export function getActionType(): ActionTypeModel {
@@ -34,7 +34,7 @@ export function getActionType(): ActionTypeModel {
iconClass: 'securityAnalyticsApp',
selectMessage: i18n.CASE_CONNECTOR_DESC,
actionTypeTitle: i18n.CASE_CONNECTOR_TITLE,
- validateConnector: () => ({ config: { errors: {} }, secrets: { errors: {} } }),
+ validateConnector: () => Promise.resolve({ config: { errors: {} }, secrets: { errors: {} } }),
validateParams,
actionConnectorFields: null,
actionParamsFields: lazy(() => import('./alert_fields')),
diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx
index 11642f4083d398..3eda13f5bcb385 100644
--- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx
+++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx
@@ -94,12 +94,12 @@ describe('alert_form', () => {
id: 'alert-action-type',
iconClass: '',
selectMessage: '',
- validateConnector: (): ConnectorValidationResult => {
- return {};
+ validateConnector: (): Promise> => {
+ return Promise.resolve({});
},
- validateParams: (): GenericValidationResult => {
+ validateParams: (): Promise> => {
const validationResult = { errors: {} };
- return validationResult;
+ return Promise.resolve(validationResult);
},
actionConnectorFields: null,
actionParamsFields: mockedActionParamsFields,
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx
index a31371c31cbbb5..8a85d35d77fac7 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx
@@ -89,6 +89,7 @@ const StepRuleActionsComponent: FC = ({
...(defaultValues ?? stepActionsDefaultValue),
kibanaSiemAppUrl: kibanaAbsoluteUrl,
};
+
const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]);
const { form } = useForm({
defaultValue: initialState,
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx
index 992f30e795bbfe..3266d6f61eeed1 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.test.tsx
@@ -15,13 +15,13 @@ describe('stepRuleActions schema', () => {
const actionTypeRegistry = actionTypeRegistryMock.create();
describe('validateSingleAction', () => {
- it('should validate single action', () => {
+ it('should validate single action', async () => {
(isUuid as jest.Mock).mockReturnValue(true);
(validateActionParams as jest.Mock).mockReturnValue([]);
(validateMustache as jest.Mock).mockReturnValue([]);
expect(
- validateSingleAction(
+ await validateSingleAction(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
@@ -33,12 +33,12 @@ describe('stepRuleActions schema', () => {
).toHaveLength(0);
});
- it('should validate single action with invalid mustache template', () => {
+ it('should validate single action with invalid mustache template', async () => {
(isUuid as jest.Mock).mockReturnValue(true);
(validateActionParams as jest.Mock).mockReturnValue([]);
(validateMustache as jest.Mock).mockReturnValue(['Message is not valid mustache template']);
- const errors = validateSingleAction(
+ const errors = await validateSingleAction(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
@@ -54,12 +54,12 @@ describe('stepRuleActions schema', () => {
expect(errors[0]).toEqual('Message is not valid mustache template');
});
- it('should validate single action with incorrect id', () => {
+ it('should validate single action with incorrect id', async () => {
(isUuid as jest.Mock).mockReturnValue(false);
(validateMustache as jest.Mock).mockReturnValue([]);
(validateActionParams as jest.Mock).mockReturnValue([]);
- const errors = validateSingleAction(
+ const errors = await validateSingleAction(
{
id: '823d4',
group: 'default',
@@ -74,10 +74,10 @@ describe('stepRuleActions schema', () => {
});
describe('validateRuleActionsField', () => {
- it('should validate rule actions field', () => {
+ it('should validate rule actions field', async () => {
const validator = validateRuleActionsField(actionTypeRegistry);
- const result = validator({
+ const result = await validator({
path: '',
value: [],
form: {} as FormHook,
@@ -88,11 +88,11 @@ describe('stepRuleActions schema', () => {
expect(result).toEqual(undefined);
});
- it('should validate incorrect rule actions field', () => {
+ it('should validate incorrect rule actions field', async () => {
(getActionTypeName as jest.Mock).mockReturnValue('Slack');
const validator = validateRuleActionsField(actionTypeRegistry);
- const result = validator({
+ const result = await validator({
path: '',
value: [
{
@@ -117,7 +117,7 @@ describe('stepRuleActions schema', () => {
});
});
- it('should validate multiple incorrect rule actions field', () => {
+ it('should validate multiple incorrect rule actions field', async () => {
(isUuid as jest.Mock).mockReturnValueOnce(false);
(getActionTypeName as jest.Mock).mockReturnValueOnce('Slack');
(isUuid as jest.Mock).mockReturnValueOnce(true);
@@ -126,7 +126,7 @@ describe('stepRuleActions schema', () => {
(validateMustache as jest.Mock).mockReturnValue(['Component is not valid mustache template']);
const validator = validateRuleActionsField(actionTypeRegistry);
- const result = validator({
+ const result = await validator({
path: '',
value: [
{
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx
index bc32bdc387cd2b..a697d922eda97a 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx
@@ -13,42 +13,46 @@ import {
AlertAction,
ActionTypeRegistryContract,
} from '../../../../../../triggers_actions_ui/public';
-import { FormSchema, ValidationFunc, ERROR_CODE } from '../../../../shared_imports';
+import {
+ FormSchema,
+ ValidationFunc,
+ ERROR_CODE,
+ ValidationError,
+} from '../../../../shared_imports';
import { ActionsStepRule } from '../../../pages/detection_engine/rules/types';
import * as I18n from './translations';
import { isUuid, getActionTypeName, validateMustache, validateActionParams } from './utils';
-export const validateSingleAction = (
+export const validateSingleAction = async (
actionItem: AlertAction,
actionTypeRegistry: ActionTypeRegistryContract
-): string[] => {
+): Promise => {
if (!isUuid(actionItem.id)) {
return [I18n.NO_CONNECTOR_SELECTED];
}
- const actionParamsErrors = validateActionParams(actionItem, actionTypeRegistry);
+ const actionParamsErrors = await validateActionParams(actionItem, actionTypeRegistry);
const mustacheErrors = validateMustache(actionItem.params);
return [...actionParamsErrors, ...mustacheErrors];
};
-export const validateRuleActionsField = (actionTypeRegistry: ActionTypeRegistryContract) => (
+export const validateRuleActionsField = (actionTypeRegistry: ActionTypeRegistryContract) => async (
...data: Parameters
-): ReturnType> | undefined => {
+): Promise | void | undefined> => {
const [{ value, path }] = data as [{ value: AlertAction[]; path: string }];
- const errors = value.reduce((acc, actionItem) => {
- const errorsArray = validateSingleAction(actionItem, actionTypeRegistry);
+ const errors = [];
+ for (const actionItem of value) {
+ const errorsArray = await validateSingleAction(actionItem, actionTypeRegistry);
if (errorsArray.length) {
const actionTypeName = getActionTypeName(actionItem.actionTypeId);
const errorsListItems = errorsArray.map((error) => `* ${error}\n`);
- return [...acc, `\n**${actionTypeName}:**\n${errorsListItems.join('')}`];
+ errors.push(`\n**${actionTypeName}:**\n${errorsListItems.join('')}`);
}
-
- return acc;
- }, [] as string[]);
+ }
if (errors.length) {
return {
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts
index 3d7299c1673b18..7c4ea71c983c88 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.test.ts
@@ -61,11 +61,11 @@ describe('stepRuleActions utils', () => {
actionTypeRegistry.get.mockReturnValue(actionMock);
});
- it('should validate action params', () => {
+ it('should validate action params', async () => {
validateParamsMock.mockReturnValue({ errors: [] });
expect(
- validateActionParams(
+ await validateActionParams(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
@@ -79,13 +79,13 @@ describe('stepRuleActions utils', () => {
).toHaveLength(0);
});
- it('should validate incorrect action params', () => {
+ it('should validate incorrect action params', async () => {
validateParamsMock.mockReturnValue({
errors: ['Message is required'],
});
expect(
- validateActionParams(
+ await validateActionParams(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
@@ -97,7 +97,7 @@ describe('stepRuleActions utils', () => {
).toHaveLength(1);
});
- it('should validate incorrect action params and filter error objects', () => {
+ it('should validate incorrect action params and filter error objects', async () => {
validateParamsMock.mockReturnValue({
errors: [
{
@@ -107,7 +107,7 @@ describe('stepRuleActions utils', () => {
});
expect(
- validateActionParams(
+ await validateActionParams(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
@@ -119,13 +119,13 @@ describe('stepRuleActions utils', () => {
).toHaveLength(0);
});
- it('should validate incorrect action params and filter duplicated errors', () => {
+ it('should validate incorrect action params and filter duplicated errors', async () => {
validateParamsMock.mockReturnValue({
errors: ['Message is required', 'Message is required', 'Message is required'],
});
expect(
- validateActionParams(
+ await validateActionParams(
{
id: '817b8bca-91d1-4729-8ee1-3a83aaafd9d4',
group: 'default',
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts
index d241d4283fc779..22363df5164a60 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/utils.ts
@@ -41,11 +41,11 @@ export const validateMustache = (params: AlertAction['params']) => {
return errors;
};
-export const validateActionParams = (
+export const validateActionParams = async (
actionItem: AlertAction,
actionTypeRegistry: ActionTypeRegistryContract
-): string[] => {
- const actionErrors = actionTypeRegistry
+): Promise => {
+ const actionErrors = await actionTypeRegistry
.get(actionItem.actionTypeId)
?.validateParams(actionItem.params);
diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md
index 7d736218af2d99..cd83be0138faf3 100644
--- a/x-pack/plugins/triggers_actions_ui/README.md
+++ b/x-pack/plugins/triggers_actions_ui/README.md
@@ -888,10 +888,10 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Send to Server log',
}
),
- validateConnector: (): ValidationResult => {
+ validateConnector: (): Promise => {
return { errors: {} };
},
- validateParams: (actionParams: ServerLogActionParams): ValidationResult => {
+ validateParams: (actionParams: ServerLogActionParams): Promise => {
// validation of action params implementation
},
actionConnectorFields: null,
@@ -929,10 +929,10 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Send to email',
}
),
- validateConnector: (action: EmailActionConnector): ValidationResult => {
+ validateConnector: (action: EmailActionConnector): Promise => {
// validation of connector properties implementation
},
- validateParams: (actionParams: EmailActionParams): ValidationResult => {
+ validateParams: (actionParams: EmailActionParams): Promise => {
// validation of action params implementation
},
actionConnectorFields: EmailActionConnectorFields,
@@ -967,10 +967,10 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Send to Slack',
}
),
- validateConnector: (action: SlackActionConnector): ValidationResult => {
+ validateConnector: (action: SlackActionConnector): Promise => {
// validation of connector properties implementation
},
- validateParams: (actionParams: SlackActionParams): ValidationResult => {
+ validateParams: (actionParams: SlackActionParams): Promise => {
// validation of action params implementation
},
actionConnectorFields: SlackActionFields,
@@ -1000,12 +1000,12 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Index data into Elasticsearch.',
}
),
- validateConnector: (): ValidationResult => {
+ validateConnector: (): Promise => {
return { errors: {} };
},
actionConnectorFields: IndexActionConnectorFields,
actionParamsFields: IndexParamsFields,
- validateParams: (): ValidationResult => {
+ validateParams: (): Promise => {
return { errors: {} };
},
};
@@ -1046,10 +1046,10 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Send a request to a web service.',
}
),
- validateConnector: (action: WebhookActionConnector): ValidationResult => {
+ validateConnector: (action: WebhookActionConnector): Promise => {
// validation of connector properties implementation
},
- validateParams: (actionParams: WebhookActionParams): ValidationResult => {
+ validateParams: (actionParams: WebhookActionParams): Promise => {
// validation of action params implementation
},
actionConnectorFields: WebhookActionConnectorFields,
@@ -1086,10 +1086,10 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Send to PagerDuty',
}
),
- validateConnector: (action: PagerDutyActionConnector): ValidationResult => {
+ validateConnector: (action: PagerDutyActionConnector): Promise => {
// validation of connector properties implementation
},
- validateParams: (actionParams: PagerDutyActionParams): ValidationResult => {
+ validateParams: (actionParams: PagerDutyActionParams): Promise => {
// validation of action params implementation
},
actionConnectorFields: PagerDutyActionConnectorFields,
@@ -1113,8 +1113,8 @@ Each action type should be defined as an `ActionTypeModel` object with the follo
iconClass: IconType;
selectMessage: string;
actionTypeTitle?: string;
- validateConnector: (connector: any) => ValidationResult;
- validateParams: (actionParams: any) => ValidationResult;
+ validateConnector: (connector: any) => Promise;
+ validateParams: (actionParams: any) => Promise;
actionConnectorFields: React.FunctionComponent | null;
actionParamsFields: React.LazyExoticComponent>>;
```
@@ -1186,7 +1186,7 @@ export function getActionType(): ActionTypeModel {
defaultMessage: 'Example Action',
}
),
- validateConnector: (action: ExampleActionConnector): ValidationResult => {
+ validateConnector: (action: ExampleActionConnector): Promise => {
const validationResult = { errors: {} };
const errors = {
someConnectorField: new Array(),
@@ -1204,7 +1204,7 @@ export function getActionType(): ActionTypeModel {
}
return validationResult;
},
- validateParams: (actionParams: ExampleActionParams): ValidationResult => {
+ validateParams: (actionParams: ExampleActionParams): Promise => {
const validationResult = { errors: {} };
const errors = {
message: new Array(),
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx
index bebddba0c11109..4d669ab4c76a1f 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx
@@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('connector validation', () => {
- test('connector validation succeeds when connector config is valid', () => {
+ test('connector validation succeeds when connector config is valid', async () => {
const actionConnector = {
secrets: {
user: 'user',
@@ -49,7 +49,7 @@ describe('connector validation', () => {
},
} as EmailActionConnector;
- expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
+ expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
@@ -66,7 +66,7 @@ describe('connector validation', () => {
});
});
- test('connector validation succeeds when connector config is valid with empty user/password', () => {
+ test('connector validation succeeds when connector config is valid with empty user/password', async () => {
const actionConnector = {
secrets: {
user: null,
@@ -85,7 +85,7 @@ describe('connector validation', () => {
},
} as EmailActionConnector;
- expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
+ expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
@@ -101,7 +101,7 @@ describe('connector validation', () => {
},
});
});
- test('connector validation fails when connector config is not valid', () => {
+ test('connector validation fails when connector config is not valid', async () => {
const actionConnector = {
secrets: {
user: 'user',
@@ -116,7 +116,7 @@ describe('connector validation', () => {
},
} as EmailActionConnector;
- expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
+ expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
@@ -132,7 +132,7 @@ describe('connector validation', () => {
},
});
});
- test('connector validation fails when user specified but not password', () => {
+ test('connector validation fails when user specified but not password', async () => {
const actionConnector = {
secrets: {
user: 'user',
@@ -151,7 +151,7 @@ describe('connector validation', () => {
},
} as EmailActionConnector;
- expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
+ expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
@@ -167,7 +167,7 @@ describe('connector validation', () => {
},
});
});
- test('connector validation fails when password specified but not user', () => {
+ test('connector validation fails when password specified but not user', async () => {
const actionConnector = {
secrets: {
user: null,
@@ -186,7 +186,7 @@ describe('connector validation', () => {
},
} as EmailActionConnector;
- expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
+ expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
from: [],
@@ -205,7 +205,7 @@ describe('connector validation', () => {
});
describe('action params validation', () => {
- test('action params validation succeeds when action params is valid', () => {
+ test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
to: [],
cc: ['test1@test.com'],
@@ -213,7 +213,7 @@ describe('action params validation', () => {
subject: 'test',
};
- expect(actionTypeModel.validateParams(actionParams)).toEqual({
+ expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
to: [],
cc: [],
@@ -224,13 +224,13 @@ describe('action params validation', () => {
});
});
- test('action params validation fails when action params is not valid', () => {
+ test('action params validation fails when action params is not valid', async () => {
const actionParams = {
to: ['test@test.com'],
subject: 'test',
};
- expect(actionTypeModel.validateParams(actionParams)).toEqual({
+ expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
to: [],
cc: [],
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx
index 81eadda4fc278c..5e237546214306 100644
--- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx
+++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx
@@ -31,9 +31,12 @@ export function getActionType(): ActionTypeModel, EmailSecrets> => {
+ ): Promise<
+ ConnectorValidationResult, EmailSecrets>
+ > => {
+ const translations = await import('./translations');
const configErrors = {
from: new Array(),
port: new Array(),
@@ -49,74 +52,25 @@ export function getActionType(): ActionTypeModel => {
+ ): Promise> => {
+ const translations = await import('./translations');
const errors = {
to: new Array(),
cc: new Array(),
@@ -146,35 +101,16 @@ export function getActionType(): ActionTypeModel