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%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}BL355g}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 0; + const isHostInvalid: boolean = + host !== undefined && errors.host !== undefined && errors.host.length > 0; + const isPortInvalid: boolean = + port !== undefined && errors.port !== undefined && errors.port.length > 0; + + const isPasswordInvalid: boolean = + password !== undefined && errors.password !== undefined && errors.password.length > 0; + const isUserInvalid: boolean = + user !== undefined && errors.user !== undefined && errors.user.length > 0; return ( <> @@ -46,7 +57,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="from" fullWidth error={errors.from} - isInvalid={errors.from.length > 0 && from !== undefined} + isInvalid={isFromInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel', { @@ -65,7 +76,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && from !== undefined} + isInvalid={isFromInvalid} name="from" value={from || ''} data-test-subj="emailFromInput" @@ -87,7 +98,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailHost" fullWidth error={errors.host} - isInvalid={errors.host.length > 0 && host !== undefined} + isInvalid={isHostInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel', { @@ -98,7 +109,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && host !== undefined} + isInvalid={isHostInvalid} name="host" value={host || ''} data-test-subj="emailHostInput" @@ -121,7 +132,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< fullWidth placeholder="587" error={errors.port} - isInvalid={errors.port.length > 0 && port !== undefined} + isInvalid={isPortInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel', { @@ -131,7 +142,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< > 0 && port !== undefined} + isInvalid={isPortInvalid} fullWidth readOnly={readOnly} name="port" @@ -221,7 +232,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailUser" fullWidth error={errors.user} - isInvalid={errors.user.length > 0 && user !== undefined} + isInvalid={isUserInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', { @@ -231,7 +242,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< > 0 && user !== undefined} + isInvalid={isUserInvalid} name="user" readOnly={readOnly} value={user || ''} @@ -252,7 +263,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< id="emailPassword" fullWidth error={errors.password} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', { @@ -263,7 +274,7 @@ export const EmailActionConnectorFields: React.FunctionComponent< 0 && password !== undefined} + isInvalid={isPasswordInvalid} name="password" value={password || ''} data-test-subj="emailPasswordInput" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx index e2d6237af85da2..5d19a1958c1c6f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx @@ -44,13 +44,18 @@ export const EmailParamsFields = ({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultMessage]); - + const isToInvalid: boolean = to !== undefined && errors.to !== undefined && errors.to.length > 0; + const isSubjectInvalid: boolean = + subject !== undefined && errors.subject !== undefined && errors.subject.length > 0; + const isCCInvalid: boolean = errors.cc !== undefined && errors.cc.length > 0 && cc !== undefined; + const isBCCInvalid: boolean = + errors.bcc !== undefined && errors.bcc.length > 0 && bcc !== undefined; return ( <> 0 && to !== undefined} + isInvalid={isToInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel', { @@ -82,7 +87,7 @@ export const EmailParamsFields = ({ > 0 && to !== undefined} + isInvalid={isToInvalid} fullWidth data-test-subj="toEmailAddressInput" selectedOptions={toOptions} @@ -112,7 +117,7 @@ export const EmailParamsFields = ({ 0 && cc !== undefined} + isInvalid={isCCInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel', { @@ -122,7 +127,7 @@ export const EmailParamsFields = ({ > 0 && cc !== undefined} + isInvalid={isCCInvalid} fullWidth data-test-subj="ccEmailAddressInput" selectedOptions={ccOptions} @@ -153,7 +158,7 @@ export const EmailParamsFields = ({ 0 && bcc !== undefined} + isInvalid={isBCCInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel', { @@ -163,7 +168,7 @@ export const EmailParamsFields = ({ > 0 && bcc !== undefined} + isInvalid={isBCCInvalid} fullWidth data-test-subj="bccEmailAddressInput" selectedOptions={bccOptions} @@ -193,7 +198,7 @@ export const EmailParamsFields = ({ 0 && subject !== undefined} + isInvalid={isSubjectInvalid} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel', { @@ -207,7 +212,7 @@ export const EmailParamsFields = ({ messageVariables={messageVariables} paramsProperty={'subject'} inputTargetValue={subject} - errors={errors.subject as string[]} + errors={(errors.subject ?? []) as string[]} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts new file mode 100644 index 00000000000000..5da9145ecec0ba --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SENDER_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText', + { + defaultMessage: 'Sender is required.', + } +); + +export const SENDER_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText', + { + defaultMessage: 'Sender is not a valid email address.', + } +); + +export const PORT_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', + { + defaultMessage: 'Port is required.', + } +); + +export const HOST_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', + { + defaultMessage: 'Host is required.', + } +); + +export const USERNAME_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthUserNameText', + { + defaultMessage: 'Username is required.', + } +); + +export const PASSWORD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredAuthPasswordText', + { + defaultMessage: 'Password is required.', + } +); + +export const PASSWORD_REQUIRED_FOR_USER_USED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } +); + +export const TO_CC_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', + { + defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } +); + +export const SUBJECT_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', + { + defaultMessage: 'Subject is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx index 9757653043175f..f43d883be7add0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('index connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -43,7 +43,7 @@ describe('index connector validation', () => { }, } as EsIndexActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { index: [], @@ -57,7 +57,7 @@ describe('index connector validation', () => { }); describe('index connector validation with minimal config', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -68,7 +68,7 @@ describe('index connector validation with minimal config', () => { }, } as EsIndexActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { index: [], @@ -82,9 +82,9 @@ describe('index connector validation with minimal config', () => { }); describe('action params validation', () => { - test('action params validation succeeds when action params are valid', () => { + test('action params validation succeeds when action params are valid', async () => { expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{ test: 1234 }], }) ).toEqual({ @@ -95,7 +95,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{ test: 1234 }], indexOverride: 'kibana-alert-history-anything', }) @@ -107,8 +107,8 @@ describe('action params validation', () => { }); }); - test('action params validation fails when action params are invalid', () => { - expect(actionTypeModel.validateParams({})).toEqual({ + test('action params validation fails when action params are invalid', async () => { + expect(await actionTypeModel.validateParams({})).toEqual({ errors: { documents: ['Document is required and should be a valid JSON object.'], indexOverride: [], @@ -116,7 +116,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], }) ).toEqual({ @@ -127,7 +127,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], indexOverride: 'kibana-alert-history-', }) @@ -139,7 +139,7 @@ describe('action params validation', () => { }); expect( - actionTypeModel.validateParams({ + await actionTypeModel.validateParams({ documents: [{}], indexOverride: 'this.is-a_string', }) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx index f4b8284c8cfa6d..80d38bda22ab38 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx @@ -31,44 +31,32 @@ export function getActionType(): ActionTypeModel, unknown> => { + ): Promise, unknown>> => { + const translations = await import('./translations'); const configErrors = { index: new Array(), }; const validationResult = { config: { errors: configErrors }, secrets: { errors: {} } }; if (!action.config.index) { - configErrors.index.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', - { - defaultMessage: 'Index is required.', - } - ) - ); + configErrors.index.push(translations.INDEX_REQUIRED); } return validationResult; }, actionConnectorFields: lazy(() => import('./es_index_connector')), actionParamsFields: lazy(() => import('./es_index_params')), - validateParams: ( + validateParams: async ( actionParams: IndexActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { documents: new Array(), indexOverride: new Array(), }; const validationResult = { errors }; if (!actionParams.documents?.length || Object.keys(actionParams.documents[0]).length === 0) { - errors.documents.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson', - { - defaultMessage: 'Document is required and should be a valid JSON object.', - } - ) - ); + errors.documents.push(translations.DOCUMENT_NOT_VALID); } if (actionParams.indexOverride) { if (!actionParams.indexOverride.startsWith(ALERT_HISTORY_PREFIX)) { @@ -85,14 +73,7 @@ export function getActionType(): ActionTypeModel 0 && index !== undefined; return ( <> @@ -95,7 +97,7 @@ const IndexActionConnectorFields: React.FunctionComponent< defaultMessage="Index" /> } - isInvalid={errors.index.length > 0 && index !== undefined} + isInvalid={isIndexInvalid} error={errors.index} helpText={ <> @@ -118,7 +120,7 @@ const IndexActionConnectorFields: React.FunctionComponent< singleSelection={{ asPlainText: true }} async isLoading={isIndiciesLoading} - isInvalid={errors.index.length > 0 && index !== undefined} + isInvalid={isIndexInvalid} noSuggestions={!indexOptions.length} options={indexOptions} data-test-subj="connectorIndexesComboBox" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index 6973cdcc7a0883..b5985cf724e096 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -117,7 +117,11 @@ export const IndexParamsFields = ({ 0} + isInvalid={ + errors.indexOverride !== undefined && + (errors.indexOverride as string[]) && + errors.indexOverride.length > 0 + } label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.preconfiguredIndex', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts new file mode 100644 index 00000000000000..b7dd6ac749909b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const INDEX_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', + { + defaultMessage: 'Index is required.', + } +); + +export const DOCUMENT_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredDocumentJson', + { + defaultMessage: 'Document is required and should be a valid JSON object.', + } +); + +export const HISTORY_NOT_VALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.badIndexOverrideSuffix', + { + defaultMessage: 'Alert history index must contain valid suffix.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx index ea1bcf82c314c3..857582fa7cdaf5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('jira connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { email: 'email', @@ -45,7 +45,7 @@ describe('jira connector validation', () => { }, } as JiraActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('jira 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: { email: 'user', @@ -72,7 +72,7 @@ describe('jira connector validation', () => { config: {}, } as unknown) as JiraActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -90,22 +90,22 @@ describe('jira connector validation', () => { }); describe('jira 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 = { subActionParams: { incident: { summary: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { subActionParams: { incident: { summary: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': ['Summary is required.'], 'subActionParams.incident.labels': [], @@ -113,7 +113,7 @@ describe('jira action params validation', () => { }); }); - test('params validation fails when labels contain spaces', () => { + test('params validation fails when labels contain spaces', async () => { const actionParams = { subActionParams: { incident: { summary: 'some title', labels: ['label with spaces'] }, @@ -121,7 +121,7 @@ describe('jira action params validation', () => { }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.summary': [], 'subActionParams.incident.labels': ['Labels cannot contain spaces.'], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx index ff7fd026f8e31b..8e3424a16c2952 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.tsx @@ -6,18 +6,19 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, ConnectorValidationResult, } from '../../../../types'; import { JiraActionConnector, JiraConfig, JiraSecrets, JiraActionParams } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: JiraActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), projectKey: new Array(), @@ -33,41 +34,58 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.config.projectKey) { - configErrors.projectKey = [...configErrors.projectKey, i18n.JIRA_PROJECT_KEY_REQUIRED]; + configErrors.projectKey = [...configErrors.projectKey, translations.JIRA_PROJECT_KEY_REQUIRED]; } if (!action.secrets.email) { - secretsErrors.email = [...secretsErrors.email, i18n.JIRA_EMAIL_REQUIRED]; + secretsErrors.email = [...secretsErrors.email, translations.JIRA_EMAIL_REQUIRED]; } if (!action.secrets.apiToken) { - secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.JIRA_API_TOKEN_REQUIRED]; + secretsErrors.apiToken = [...secretsErrors.apiToken, translations.JIRA_API_TOKEN_REQUIRED]; } return validationResult; }; +export const JIRA_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText', + { + defaultMessage: 'Create an incident in Jira.', + } +); + +export const JIRA_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle', + { + defaultMessage: 'Jira', + } +); + export function getActionType(): ActionTypeModel { return { id: '.jira', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.JIRA_DESC, - actionTypeTitle: i18n.JIRA_TITLE, + selectMessage: JIRA_DESC, + actionTypeTitle: JIRA_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./jira_connectors')), - validateParams: (actionParams: JiraActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: JiraActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { 'subActionParams.incident.summary': new Array(), 'subActionParams.incident.labels': new Array(), @@ -80,13 +98,13 @@ export function getActionType(): ActionTypeModel label.match(/\s/g))) - errors['subActionParams.incident.labels'].push(i18n.LABELS_WHITE_SPACES); + errors['subActionParams.incident.labels'].push(translations.LABELS_WHITE_SPACES); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx index f2753310d73aeb..7aec0a405d0d5e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_connectors.tsx @@ -32,13 +32,17 @@ const JiraConnectorFields: React.FC { const { apiUrl, projectKey } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + apiUrl !== undefined && errors.apiUrl !== undefined && errors.apiUrl.length > 0; const { email, apiToken } = action.secrets; - const isProjectKeyInvalid: boolean = errors.projectKey.length > 0 && projectKey !== undefined; - const isEmailInvalid: boolean = errors.email.length > 0 && email !== undefined; - const isApiTokenInvalid: boolean = errors.apiToken.length > 0 && apiToken !== undefined; + const isProjectKeyInvalid: boolean = + projectKey !== undefined && errors.projectKey !== undefined && errors.projectKey.length > 0; + const isEmailInvalid: boolean = + email !== undefined && errors.email !== undefined && errors.email.length > 0; + const isApiTokenInvalid: boolean = + apiToken !== undefined && errors.apiToken !== undefined && errors.apiToken.length > 0; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index 11123a81440bba..5897de46f94df7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -186,6 +186,7 @@ const JiraParamsFields: React.FunctionComponent 0 && incident.labels !== undefined; @@ -277,6 +278,7 @@ const JiraParamsFields: React.FunctionComponent 0 && incident.summary !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts index 4577e55260d9d9..5904eb05c31b6e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/translations.ts @@ -7,20 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const JIRA_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.jira.selectMessageText', - { - defaultMessage: 'Create an incident in Jira.', - } -); - -export const JIRA_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.jira.actionTypeTitle', - { - defaultMessage: 'Jira', - } -); - export const API_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.jira.apiUrlTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx index eae8690dbdd988..d96ca76aea3be8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('pagerduty connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { routingKey: 'test', @@ -43,7 +43,7 @@ describe('pagerduty connector validation', () => { }, } as PagerDutyActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: [], @@ -53,7 +53,7 @@ describe('pagerduty connector validation', () => { delete actionConnector.config.apiUrl; actionConnector.secrets.routingKey = 'test1'; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: [], @@ -62,7 +62,7 @@ describe('pagerduty 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: {}, id: 'test', @@ -73,7 +73,7 @@ describe('pagerduty connector validation', () => { }, } as PagerDutyActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ secrets: { errors: { routingKey: ['An integration key / routing key is required.'], @@ -84,7 +84,7 @@ describe('pagerduty connector validation', () => { }); describe('pagerduty 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 = { eventAction: 'trigger', dedupKey: 'test', @@ -97,7 +97,7 @@ describe('pagerduty action params validation', () => { class: 'test class', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { dedupKey: [], summary: [], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx index 310c5cae24566c..80dd360d620b2d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx @@ -42,9 +42,10 @@ export function getActionType(): ActionTypeModel< defaultMessage: 'Send to PagerDuty', } ), - validateConnector: ( + validateConnector: async ( action: PagerDutyActionConnector - ): ConnectorValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { routingKey: new Array(), }; @@ -53,22 +54,16 @@ export function getActionType(): ActionTypeModel< }; if (!action.secrets.routingKey) { - secretsErrors.routingKey.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', - { - defaultMessage: 'An integration key / routing key is required.', - } - ) - ); + secretsErrors.routingKey.push(translations.INTEGRATION_KEY_REQUIRED); } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: PagerDutyActionParams - ): GenericValidationResult< - Pick + ): Promise< + GenericValidationResult> > => { + const translations = await import('./translations'); const errors = { summary: new Array(), timestamp: new Array(), @@ -79,27 +74,13 @@ export function getActionType(): ActionTypeModel< !actionParams.dedupKey?.length && (actionParams.eventAction === 'resolve' || actionParams.eventAction === 'acknowledge') ) { - errors.dedupKey.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText', - { - defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.', - } - ) - ); + errors.dedupKey.push(translations.DEDUP_KEY_REQUIRED); } if ( actionParams.eventAction === EventActionOptions.TRIGGER && !actionParams.summary?.length ) { - errors.summary.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', - { - defaultMessage: 'Summary is required.', - } - ) - ); + errors.summary.push(translations.SUMMARY_REQUIRED); } if (actionParams.timestamp && !hasMustacheTokens(actionParams.timestamp)) { if (isNaN(Date.parse(actionParams.timestamp))) { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx index 7e9a5770c2158e..3ac7832d0462ee 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx @@ -20,6 +20,9 @@ const PagerDutyActionConnectorFields: React.FunctionComponent< const { docLinks } = useKibana().services; const { apiUrl } = action.config; const { routingKey } = action.secrets; + const isRoutingKeyInvalid: boolean = + routingKey !== undefined && errors.routingKey !== undefined && errors.routingKey.length > 0; + return ( <> } error={errors.routingKey} - isInvalid={errors.routingKey.length > 0 && routingKey !== undefined} + isInvalid={isRoutingKeyInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel', { @@ -80,7 +83,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent< )} 0 && routingKey !== undefined} + isInvalid={isRoutingKeyInvalid} name="routingKey" readOnly={readOnly} value={routingKey || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx index 4961a27fd0ac15..8605832b92ea59 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx @@ -101,6 +101,12 @@ const PagerDutyParamsFields: React.FunctionComponent 0; + const isSummaryInvalid: boolean = + errors.summary !== undefined && errors.summary.length > 0 && summary !== undefined; + const isTimestampInvalid: boolean = + errors.timestamp !== undefined && errors.timestamp.length > 0 && timestamp !== undefined; + return ( <> @@ -132,7 +138,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0} + isInvalid={isDedupKeyInvalid} label={ isDedupeKeyRequired ? i18n.translate( @@ -166,7 +172,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0 && summary !== undefined} + isInvalid={isSummaryInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.summaryFieldLabel', { @@ -180,7 +186,7 @@ const PagerDutyParamsFields: React.FunctionComponent @@ -211,7 +217,7 @@ const PagerDutyParamsFields: React.FunctionComponent 0 && timestamp !== undefined} + isInvalid={isTimestampInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.timestampTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts new file mode 100644 index 00000000000000..a907b19a1d7337 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SUMMARY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', + { + defaultMessage: 'Summary is required.', + } +); + +export const DEDUP_KEY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredDedupKeyText', + { + defaultMessage: 'DedupKey is required when resolving or acknowledging an incident.', + } +); + +export const INTEGRATION_KEY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', + { + defaultMessage: 'An integration key / routing key is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx index 892ab97b8627f9..93fb419f509bce 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('resilient connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { apiKeyId: 'email', @@ -45,7 +45,7 @@ describe('resilient connector validation', () => { }, } as ResilientActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('resilient 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: { apiKeyId: 'user', @@ -72,7 +72,7 @@ describe('resilient connector validation', () => { config: {}, } as unknown) as ResilientActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -90,22 +90,22 @@ describe('resilient connector validation', () => { }); describe('resilient 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 = { subActionParams: { incident: { name: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.name': [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { subActionParams: { incident: { name: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { 'subActionParams.incident.name': ['Name is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx index e7074b7506e7a6..f20204af17697f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx @@ -6,6 +6,7 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, @@ -17,12 +18,12 @@ import { ResilientSecrets, ResilientActionParams, } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: ResilientActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), orgId: new Array(), @@ -38,32 +39,49 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.config.orgId) { - configErrors.orgId = [...configErrors.orgId, i18n.ORG_ID_REQUIRED]; + configErrors.orgId = [...configErrors.orgId, translations.ORG_ID_REQUIRED]; } if (!action.secrets.apiKeyId) { - secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, i18n.API_KEY_ID_REQUIRED]; + secretsErrors.apiKeyId = [...secretsErrors.apiKeyId, translations.API_KEY_ID_REQUIRED]; } if (!action.secrets.apiKeySecret) { - secretsErrors.apiKeySecret = [...secretsErrors.apiKeySecret, i18n.API_KEY_SECRET_REQUIRED]; + secretsErrors.apiKeySecret = [ + ...secretsErrors.apiKeySecret, + translations.API_KEY_SECRET_REQUIRED, + ]; } return validationResult; }; +export const DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText', + { + defaultMessage: 'Create an incident in IBM Resilient.', + } +); + +export const TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.actionTypeTitle', + { + defaultMessage: 'Resilient', + } +); + export function getActionType(): ActionTypeModel< ResilientConfig, ResilientSecrets, @@ -72,11 +90,14 @@ export function getActionType(): ActionTypeModel< return { id: '.resilient', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.DESC, - actionTypeTitle: i18n.TITLE, + selectMessage: DESC, + actionTypeTitle: TITLE, validateConnector, actionConnectorFields: lazy(() => import('./resilient_connectors')), - validateParams: (actionParams: ResilientActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: ResilientActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { 'subActionParams.incident.name': new Array(), }; @@ -88,7 +109,7 @@ export function getActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.name?.length ) { - errors['subActionParams.incident.name'].push(i18n.NAME_REQUIRED); + errors['subActionParams.incident.name'].push(translations.NAME_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx index 6996062899c39c..1270f19820f4ce 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx @@ -30,14 +30,19 @@ const ResilientConnectorFields: React.FC { const { apiUrl, orgId } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + apiUrl !== undefined && errors.apiUrl !== undefined && errors.apiUrl.length > 0; const { apiKeyId, apiKeySecret } = action.secrets; - const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId !== undefined; - const isApiKeyInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId !== undefined; + const isOrgIdInvalid: boolean = + orgId !== undefined && errors.orgId !== undefined && errors.orgId.length > 0; + const isApiKeyInvalid: boolean = + apiKeyId !== undefined && errors.apiKeyId !== undefined && errors.apiKeyId.length > 0; const isApiKeySecretInvalid: boolean = - errors.apiKeySecret.length > 0 && apiKeySecret !== undefined; + apiKeySecret !== undefined && + errors.apiKeySecret !== undefined && + errors.apiKeySecret.length > 0; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx index 4642226d402221..54a138a2bc7cfc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -213,7 +213,9 @@ const ResilientParamsFields: React.FunctionComponent 0 && incident.name !== undefined + errors['subActionParams.incident.name'] !== undefined && + errors['subActionParams.incident.name'].length > 0 && + incident.name !== undefined } label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.nameFieldLabel', @@ -226,7 +228,7 @@ const ResilientParamsFields: React.FunctionComponent { }); describe('server-log connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector: UserConfiguredActionConnector<{}, {}> = { secrets: {}, id: 'test', @@ -39,7 +39,7 @@ describe('server-log connector validation', () => { isPreconfigured: false, }; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -51,23 +51,23 @@ describe('server-log 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 = { message: 'test message', level: 'trace', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx index 4550d2d65b9df0..066c5c0a2f3858 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx @@ -30,12 +30,12 @@ export function getActionType(): ActionTypeModel => { - return { config: { errors: {} }, secrets: { errors: {} } }; + validateConnector: (): Promise> => { + return Promise.resolve({ config: { errors: {} }, secrets: { errors: {} } }); }, validateParams: ( actionParams: ServerLogActionParams - ): GenericValidationResult> => { + ): Promise>> => { const errors = { message: new Array(), }; @@ -50,7 +50,7 @@ export function getActionType(): ActionTypeModel import('./server_log_params')), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index 02ecab47ae49a0..e25e8120b1650a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { describe('servicenow connector validation', () => { [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { - test(`${id}: connector validation succeeds when connector config is valid`, () => { + test(`${id}: connector validation succeeds when connector config is valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionConnector = { secrets: { @@ -46,7 +46,7 @@ describe('servicenow connector validation', () => { }, } as ServiceNowActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: [], @@ -61,7 +61,7 @@ describe('servicenow connector validation', () => { }); }); - test(`${id}: connector validation fails when connector config is not valid`, () => { + test(`${id}: connector validation fails when connector config is not valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionConnector = ({ secrets: { @@ -73,7 +73,7 @@ describe('servicenow connector validation', () => { config: {}, } as unknown) as ServiceNowActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { apiUrl: ['URL is required.'], @@ -92,24 +92,24 @@ describe('servicenow connector validation', () => { describe('servicenow action params validation', () => { [SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => { - test(`${id}: action params validation succeeds when action params is valid`, () => { + test(`${id}: action params validation succeeds when action params is valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionParams = { subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { ['subActionParams.incident.short_description']: [] }, }); }); - test(`${id}: params validation fails when body is not valid`, () => { + test(`${id}: params validation fails when body is not valid`, async () => { const actionTypeModel = actionTypeRegistry.get(id); const actionParams = { subActionParams: { incident: { short_description: '' }, comments: [] }, }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { ['subActionParams.incident.short_description']: ['Short description is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index a6cc116d3d7b4b..24e2a87d423579 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -6,6 +6,7 @@ */ import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; import { GenericValidationResult, ActionTypeModel, @@ -18,12 +19,12 @@ import { ServiceNowITSMActionParams, ServiceNowSIRActionParams, } from './types'; -import * as i18n from './translations'; import { isValidUrl } from '../../../lib/value_validators'; -const validateConnector = ( +const validateConnector = async ( action: ServiceNowActionConnector -): ConnectorValidationResult => { +): Promise> => { + const translations = await import('./translations'); const configErrors = { apiUrl: new Array(), }; @@ -38,28 +39,56 @@ const validateConnector = ( }; if (!action.config.apiUrl) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRED]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRED]; } if (action.config.apiUrl) { if (!isValidUrl(action.config.apiUrl)) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_INVALID]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_INVALID]; } else if (!isValidUrl(action.config.apiUrl, 'https:')) { - configErrors.apiUrl = [...configErrors.apiUrl, i18n.API_URL_REQUIRE_HTTPS]; + configErrors.apiUrl = [...configErrors.apiUrl, translations.API_URL_REQUIRE_HTTPS]; } } if (!action.secrets.username) { - secretsErrors.username = [...secretsErrors.username, i18n.USERNAME_REQUIRED]; + secretsErrors.username = [...secretsErrors.username, translations.USERNAME_REQUIRED]; } if (!action.secrets.password) { - secretsErrors.password = [...secretsErrors.password, i18n.PASSWORD_REQUIRED]; + secretsErrors.password = [...secretsErrors.password, translations.PASSWORD_REQUIRED]; } return validationResult; }; +export const SERVICENOW_ITSM_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText', + { + defaultMessage: 'Create an incident in ServiceNow ITSM.', + } +); + +export const SERVICENOW_SIR_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', + { + defaultMessage: 'Create an incident in ServiceNow SecOps.', + } +); + +export const SERVICENOW_ITSM_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle', + { + defaultMessage: 'ServiceNow ITSM', + } +); + +export const SERVICENOW_SIR_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', + { + defaultMessage: 'ServiceNow SecOps', + } +); + export function getServiceNowITSMActionType(): ActionTypeModel< ServiceNowConfig, ServiceNowSecrets, @@ -68,13 +97,14 @@ export function getServiceNowITSMActionType(): ActionTypeModel< return { id: '.servicenow', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.SERVICENOW_ITSM_DESC, - actionTypeTitle: i18n.SERVICENOW_ITSM_TITLE, + selectMessage: SERVICENOW_ITSM_DESC, + actionTypeTitle: SERVICENOW_ITSM_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: ( + validateParams: async ( actionParams: ServiceNowITSMActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { // eslint-disable-next-line @typescript-eslint/naming-convention 'subActionParams.incident.short_description': new Array(), @@ -87,7 +117,7 @@ export function getServiceNowITSMActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.short_description?.length ) { - errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED); + errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); } return validationResult; }, @@ -103,11 +133,14 @@ export function getServiceNowSIRActionType(): ActionTypeModel< return { id: '.servicenow-sir', iconClass: lazy(() => import('./logo')), - selectMessage: i18n.SERVICENOW_SIR_DESC, - actionTypeTitle: i18n.SERVICENOW_SIR_TITLE, + selectMessage: SERVICENOW_SIR_DESC, + actionTypeTitle: SERVICENOW_SIR_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: (actionParams: ServiceNowSIRActionParams): GenericValidationResult => { + validateParams: async ( + actionParams: ServiceNowSIRActionParams + ): Promise> => { + const translations = await import('./translations'); const errors = { // eslint-disable-next-line @typescript-eslint/naming-convention 'subActionParams.incident.short_description': new Array(), @@ -120,7 +153,7 @@ export function getServiceNowSIRActionType(): ActionTypeModel< actionParams.subActionParams.incident && !actionParams.subActionParams.incident.short_description?.length ) { - errors['subActionParams.incident.short_description'].push(i18n.TITLE_REQUIRED); + errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index e7b2c4bac59148..c9aafc58f3ede9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -32,12 +32,15 @@ const ServiceNowConnectorFields: React.FC< const { docLinks } = useKibana().services; const { apiUrl } = action.config; - const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isApiUrlInvalid: boolean = + errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined; const { username, password } = action.secrets; - const isUsernameInvalid: boolean = errors.username.length > 0 && username !== undefined; - const isPasswordInvalid: boolean = errors.password.length > 0 && password !== undefined; + const isUsernameInvalid: boolean = + errors.username !== undefined && errors.username.length > 0 && username !== undefined; + const isPasswordInvalid: boolean = + errors.password !== undefined && errors.password.length > 0 && password !== undefined; const handleOnChangeActionConfig = useCallback( (key: string, value: string) => editActionConfig(key, value), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index dbd6fec3dad190..f0fc5ed42d24ca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -240,6 +240,7 @@ const ServiceNowParamsFields: React.FunctionComponent< fullWidth error={errors['subActionParams.incident.short_description']} isInvalid={ + errors['subActionParams.incident.short_description'] !== undefined && errors['subActionParams.incident.short_description'].length > 0 && incident.short_description !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index be6756b1c1049d..a991ee29c85f80 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -151,6 +151,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< fullWidth error={errors['subActionParams.incident.short_description']} isInvalid={ + errors['subActionParams.incident.short_description'] !== undefined && errors['subActionParams.incident.short_description'].length > 0 && incident.short_description !== undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 288b6e629112d2..ea646b896f5e93 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -7,34 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const SERVICENOW_ITSM_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText', - { - defaultMessage: 'Create an incident in ServiceNow ITSM.', - } -); - -export const SERVICENOW_SIR_DESC = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', - { - defaultMessage: 'Create an incident in ServiceNow SecOps.', - } -); - -export const SERVICENOW_ITSM_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle', - { - defaultMessage: 'ServiceNow ITSM', - } -); - -export const SERVICENOW_SIR_TITLE = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', - { - defaultMessage: 'ServiceNow SecOps', - } -); - export const API_URL_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx index eabb63567ea864..dbdc123e0098fe 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('slack connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { webhookUrl: 'https:\\test', @@ -41,7 +41,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -53,7 +53,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - no webhook url', () => { + test('connector validation fails when connector config is not valid - no webhook url', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -62,7 +62,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -74,7 +74,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook protocol', () => { + test('connector validation fails when connector config is not valid - invalid webhook protocol', async () => { const actionConnector = { secrets: { webhookUrl: 'http:\\test', @@ -85,7 +85,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -97,7 +97,7 @@ describe('slack connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url', () => { + test('connector validation fails when connector config is not valid - invalid webhook url', async () => { const actionConnector = { secrets: { webhookUrl: 'h', @@ -108,7 +108,7 @@ describe('slack connector validation', () => { config: {}, } as SlackActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -122,22 +122,22 @@ describe('slack connector validation', () => { }); describe('slack action params validation', () => { - test('if action params validation succeeds when action params is valid', () => { + test('if action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx index 30e60a6ac01568..d3df034a90bf2d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx @@ -31,61 +31,35 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { webhookUrl: new Array(), }; const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } }; if (!action.secrets.webhookUrl) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', - { - defaultMessage: 'Webhook URL is required.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_REQUIRED); } else if (action.secrets.webhookUrl) { if (!isValidUrl(action.secrets.webhookUrl)) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText', - { - defaultMessage: 'Webhook URL is invalid.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_INVALID); } else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText', - { - defaultMessage: 'Webhook URL must start with https://.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_HTTP_INVALID); } } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: SlackActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { message: new Array(), }; const validationResult = { errors }; if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); + errors.message.push(translations.MESSAGE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx index ce6cda1294adc7..e87b00dca93435 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -19,6 +19,8 @@ const SlackActionFields: React.FunctionComponent< > = ({ action, editActionSecrets, errors, readOnly }) => { const { docLinks } = useKibana().services; const { webhookUrl } = action.secrets; + const isWebhookUrlInvalid: boolean = + errors.webhookUrl !== undefined && errors.webhookUrl.length > 0 && webhookUrl !== undefined; return ( <> @@ -34,7 +36,7 @@ const SlackActionFields: React.FunctionComponent< } error={errors.webhookUrl} - isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel', { @@ -54,7 +56,7 @@ const SlackActionFields: React.FunctionComponent< )} 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} name="webhookUrl" readOnly={readOnly} value={webhookUrl || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx index 3aa7fd82274961..59e10277cfe084 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx @@ -49,7 +49,7 @@ const SlackParamsFields: React.FunctionComponent ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts new file mode 100644 index 00000000000000..bd1fd8ea194f69 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/translations.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const WEBHOOK_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } +); + +export const WEBHOOK_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.invalidWebhookUrlText', + { + defaultMessage: 'Webhook URL is invalid.', + } +); + +export const WEBHOOK_URL_HTTP_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requireHttpsWebhookUrlText', + { + defaultMessage: 'Webhook URL must start with https://.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', + { + defaultMessage: 'Message is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx index 62be20a9bad904..641c46af6bfc1d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx @@ -29,7 +29,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('teams connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { + test('connector validation succeeds when connector config is valid', async () => { const actionConnector = { secrets: { webhookUrl: 'https:\\test', @@ -40,7 +40,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -52,7 +52,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - empty webhook url', () => { + test('connector validation fails when connector config is not valid - empty webhook url', async () => { const actionConnector = { secrets: {}, id: 'test', @@ -61,7 +61,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -73,7 +73,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url', () => { + test('connector validation fails when connector config is not valid - invalid webhook url', async () => { const actionConnector = { secrets: { webhookUrl: 'h', @@ -84,7 +84,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -96,7 +96,7 @@ describe('teams connector validation', () => { }); }); - test('connector validation fails when connector config is not valid - invalid webhook url protocol', () => { + test('connector validation fails when connector config is not valid - invalid webhook url protocol', async () => { const actionConnector = { secrets: { webhookUrl: 'http://insecure', @@ -107,7 +107,7 @@ describe('teams connector validation', () => { config: {}, } as TeamsActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: {}, }, @@ -121,22 +121,22 @@ describe('teams connector validation', () => { }); describe('teams action params validation', () => { - test('if action params validation succeeds when action params is valid', () => { + test('if action params validation succeeds when action params is valid', async () => { const actionParams = { message: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: [] }, }); }); - test('params validation fails when message is not valid', () => { + test('params validation fails when message is not valid', async () => { const actionParams = { message: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { message: ['Message is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx index e8c7be7311c1ce..c48b4f950855d3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.tsx @@ -31,61 +31,35 @@ export function getActionType(): ActionTypeModel => { + ): Promise> => { + const translations = await import('./translations'); const secretsErrors = { webhookUrl: new Array(), }; const validationResult = { config: { errors: {} }, secrets: { errors: secretsErrors } }; if (!action.secrets.webhookUrl) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText', - { - defaultMessage: 'Webhook URL is required.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_REQUIRED); } else if (action.secrets.webhookUrl) { if (!isValidUrl(action.secrets.webhookUrl)) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText', - { - defaultMessage: 'Webhook URL is invalid.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_INVALID); } else if (!isValidUrl(action.secrets.webhookUrl, 'https:')) { - secretsErrors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText', - { - defaultMessage: 'Webhook URL must start with https://.', - } - ) - ); + secretsErrors.webhookUrl.push(translations.WEBHOOK_URL_HTTP_INVALID); } } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: TeamsActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { message: new Array(), }; const validationResult = { errors }; if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); + errors.message.push(translations.MESSAGE_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx index 454b938692225e..8de1c68926f144 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_connectors.tsx @@ -20,6 +20,9 @@ const TeamsActionFields: React.FunctionComponent< const { webhookUrl } = action.secrets; const { docLinks } = useKibana().services; + const isWebhookUrlInvalid: boolean = + errors.webhookUrl !== undefined && errors.webhookUrl.length > 0 && webhookUrl !== undefined; + return ( <> } error={errors.webhookUrl} - isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.webhookUrlTextFieldLabel', { @@ -54,7 +57,7 @@ const TeamsActionFields: React.FunctionComponent< )} 0 && webhookUrl !== undefined} + isInvalid={isWebhookUrlInvalid} name="webhookUrl" readOnly={readOnly} value={webhookUrl || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx index c0a20e214b4e1a..0aea576c10b316 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams_params.tsx @@ -40,7 +40,7 @@ const TeamsParamsFields: React.FunctionComponent ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts new file mode 100644 index 00000000000000..790a3b3bac32f6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/translations.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const WEBHOOK_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } +); + +export const WEBHOOK_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.invalidWebhookUrlText', + { + defaultMessage: 'Webhook URL is invalid.', + } +); + +export const WEBHOOK_URL_HTTP_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requireHttpsWebhookUrlText', + { + defaultMessage: 'Webhook URL must start with https://.', + } +); + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.teamsAction.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts new file mode 100644 index 00000000000000..3550121e81694c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/translations.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', + { + defaultMessage: 'URL is required.', + } +); + +export const URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField', + { + defaultMessage: 'URL is invalid.', + } +); + +export const METHOD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', + { + defaultMessage: 'Method is required.', + } +); + +export const USERNAME_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText', + { + defaultMessage: 'Username is required.', + } +); + +export const PASSWORD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText', + { + defaultMessage: 'Password is required.', + } +); + +export const PASSWORD_REQUIRED_FOR_USER = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } +); + +export const USERNAME_REQUIRED_FOR_PASSWORD = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText', + { + defaultMessage: 'Username is required when password is used.', + } +); + +export const BODY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', + { + defaultMessage: 'Body is required.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx index 8399316044f33d..3e42e7965c5bde 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx @@ -30,7 +30,7 @@ describe('actionTypeRegistry.get() works', () => { }); describe('webhook connector validation', () => { - test('connector validation succeeds when hasAuth is true and connector config is valid', () => { + test('connector validation succeeds when hasAuth is true and connector config is valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -48,7 +48,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: [], @@ -64,7 +64,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation succeeds when hasAuth is false and connector config is valid', () => { + test('connector validation succeeds when hasAuth is false and connector config is valid', async () => { const actionConnector = { secrets: { user: '', @@ -82,7 +82,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: [], @@ -98,7 +98,7 @@ describe('webhook 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', @@ -112,7 +112,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: ['URL is required.'], @@ -128,7 +128,7 @@ describe('webhook connector validation', () => { }); }); - test('connector validation fails when url in config is not valid', () => { + test('connector validation fails when url in config is not valid', async () => { const actionConnector = { secrets: { user: 'user', @@ -144,7 +144,7 @@ describe('webhook connector validation', () => { }, } as WebhookActionConnector; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { url: ['URL is invalid.'], @@ -162,22 +162,22 @@ describe('webhook connector validation', () => { }); describe('webhook 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 = { body: 'message {test}', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { body: [] }, }); }); - test('params validation fails when body is not valid', () => { + test('params validation fails when body is not valid', async () => { const actionParams = { body: '', }; - expect(actionTypeModel.validateParams(actionParams)).toEqual({ + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { body: ['Body is required.'], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx index 3ba801b83c46c0..a668f531a6d4cb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx @@ -40,9 +40,12 @@ export function getActionType(): ActionTypeModel< defaultMessage: 'Webhook data', } ), - validateConnector: ( + validateConnector: async ( action: WebhookActionConnector - ): ConnectorValidationResult, WebhookSecrets> => { + ): Promise< + ConnectorValidationResult, WebhookSecrets> + > => { + const translations = await import('./translations'); const configErrors = { url: new Array(), method: new Array(), @@ -56,95 +59,39 @@ export function getActionType(): ActionTypeModel< secrets: { errors: secretsErrors }, }; if (!action.config.url) { - configErrors.url.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', - { - defaultMessage: 'URL is required.', - } - ) - ); + configErrors.url.push(translations.URL_REQUIRED); } if (action.config.url && !isValidUrl(action.config.url)) { - configErrors.url = [ - ...configErrors.url, - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.invalidUrlTextField', - { - defaultMessage: 'URL is invalid.', - } - ), - ]; + configErrors.url = [...configErrors.url, translations.URL_INVALID]; } if (!action.config.method) { - configErrors.method.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', - { - defaultMessage: 'Method is required.', - } - ) - ); + configErrors.method.push(translations.METHOD_REQUIRED); } if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) { - secretsErrors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthUserNameText', - { - defaultMessage: 'Username is required.', - } - ) - ); + secretsErrors.user.push(translations.USERNAME_REQUIRED); } if (action.config.hasAuth && !action.secrets.user && !action.secrets.password) { - secretsErrors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredAuthPasswordText', - { - defaultMessage: 'Password is required.', - } - ) - ); + secretsErrors.password.push(translations.PASSWORD_REQUIRED); } if (action.secrets.user && !action.secrets.password) { - secretsErrors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', - { - defaultMessage: 'Password is required when username is used.', - } - ) - ); + secretsErrors.password.push(translations.PASSWORD_REQUIRED_FOR_USER); } if (!action.secrets.user && action.secrets.password) { - secretsErrors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredUserText', - { - defaultMessage: 'Username is required when password is used.', - } - ) - ); + secretsErrors.user.push(translations.USERNAME_REQUIRED_FOR_PASSWORD); } return validationResult; }, - validateParams: ( + validateParams: async ( actionParams: WebhookActionParams - ): GenericValidationResult => { + ): Promise> => { + const translations = await import('./translations'); const errors = { body: new Array(), }; const validationResult = { errors }; validationResult.errors = errors; if (!actionParams.body?.length) { - errors.body.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', - { - defaultMessage: 'Body is required.', - } - ) - ); + errors.body.push(translations.BODY_REQUIRED); } return validationResult; }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx index d3231f52b4d7bf..ba0e7016caa760 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx @@ -76,7 +76,11 @@ const WebhookActionConnectorFields: React.FunctionComponent< ) ); } - const hasHeaderErrors = headerErrors.keyHeader.length > 0 || headerErrors.valueHeader.length > 0; + const hasHeaderErrors: boolean = + (headerErrors.keyHeader !== undefined && + headerErrors.valueHeader !== undefined && + headerErrors.keyHeader.length > 0) || + headerErrors.valueHeader.length > 0; function addHeader() { if (headers && !!Object.keys(headers).find((key) => key === httpHeaderKey)) { @@ -219,6 +223,13 @@ const WebhookActionConnectorFields: React.FunctionComponent< ); }); + const isUrlInvalid: boolean = + errors.url !== undefined && errors.url.length > 0 && url !== undefined; + const isPasswordInvalid: boolean = + password !== undefined && errors.password !== undefined && errors.password.length > 0; + const isUserInvalid: boolean = + user !== undefined && errors.user !== undefined && errors.user.length > 0; + return ( <> @@ -248,7 +259,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="url" fullWidth error={errors.url} - isInvalid={errors.url.length > 0 && url !== undefined} + isInvalid={isUrlInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.urlTextFieldLabel', { @@ -258,7 +269,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< > 0 && url !== undefined} + isInvalid={isUrlInvalid} fullWidth readOnly={readOnly} value={url || ''} @@ -326,7 +337,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="webhookUser" fullWidth error={errors.user} - isInvalid={errors.user.length > 0 && user !== undefined} + isInvalid={isUserInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.userTextFieldLabel', { @@ -336,7 +347,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< > 0 && user !== undefined} + isInvalid={isUserInvalid} name="user" readOnly={readOnly} value={user || ''} @@ -357,7 +368,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< id="webhookPassword" fullWidth error={errors.password} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.passwordTextFieldLabel', { @@ -369,7 +380,7 @@ const WebhookActionConnectorFields: React.FunctionComponent< fullWidth name="password" readOnly={readOnly} - isInvalid={errors.password.length > 0 && password !== undefined} + isInvalid={isPasswordInvalid} value={password || ''} data-test-subj="webhookPasswordInput" onChange={(e) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index 964f538d549716..091ea1e305e35c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -23,12 +23,12 @@ describe('action_connector_form', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, }); actionTypeRegistry.get.mockReturnValue(actionType); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 0790dce9ca3d4d..29232940da5c36 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -51,11 +51,11 @@ export function validateBaseProperties( return validationResult; } -export function getConnectorErrors( +export async function getConnectorErrors( connector: UserConfiguredActionConnector, actionTypeModel: ActionTypeModel ) { - const connectorValidationResult = actionTypeModel?.validateConnector(connector); + const connectorValidationResult = await actionTypeModel?.validateConnector(connector); const configErrors = (connectorValidationResult.config ? connectorValidationResult.config.errors : {}) as IErrorObject; @@ -173,7 +173,8 @@ export const ActionConnectorForm = ({ ); const FieldsComponent = actionTypeRegistered.actionConnectorFields; - + const isNameInvalid: boolean = + connector.name !== undefined && errors.name !== undefined && errors.name.length > 0; return ( } - isInvalid={errors.name.length > 0 && connector.name !== undefined} + isInvalid={isNameInvalid} error={errors.name} > 0 && connector.name !== undefined} + isInvalid={isNameInvalid} name="name" placeholder="Untitled" data-test-subj="nameInput" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index ad727be58280f4..bedde696e51c02 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -53,12 +53,12 @@ describe('action_form', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - 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, @@ -68,12 +68,12 @@ describe('action_form', () => { id: 'disabled-by-config', iconClass: 'test', selectMessage: 'test', - 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, @@ -83,12 +83,12 @@ describe('action_form', () => { id: '.jira', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): ValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, actionParamsFields: mockedActionParamsFields, @@ -98,12 +98,12 @@ describe('action_form', () => { id: 'disabled-by-license', iconClass: 'test', selectMessage: 'test', - 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, @@ -113,12 +113,12 @@ describe('action_form', () => { id: 'preconfigured', iconClass: 'test', selectMessage: 'test', - 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/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index e9f79633ef5207..f12ce25abc4921 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -30,7 +30,7 @@ import { ActionTypeRegistryContract, } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; -import { ActionTypeForm, ActionTypeFormProps } from './action_type_form'; +import { ActionTypeForm } from './action_type_form'; import { AddConnectorInline } from './connector_add_inline'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; @@ -357,49 +357,42 @@ export const ActionForm = ({ ); } - const actionParamsErrors: ActionTypeFormProps['actionParamsErrors'] = actionTypeRegistry - .get(actionItem.actionTypeId) - ?.validateParams(actionItem.params); - return ( - - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: [index] }); - setAddModalVisibility(true); - }} - onConnectorSelected={(id: string) => { - setActionIdByIndex(id, index); - }} - actionTypeRegistry={actionTypeRegistry} - onDeleteAction={() => { - const updatedActions = actions.filter( - (_item: AlertAction, i: number) => i !== index - ); - setActions(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id) - .length === 0 - ); - setActiveActionItem(undefined); - }} - /> - - + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: [index] }); + setAddModalVisibility(true); + }} + onConnectorSelected={(id: string) => { + setActionIdByIndex(id, index); + }} + actionTypeRegistry={actionTypeRegistry} + onDeleteAction={() => { + const updatedActions = actions.filter( + (_item: AlertAction, i: number) => i !== index + ); + setActions(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === + 0 + ); + setActiveActionItem(undefined); + }} + /> ); })} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx index 38f1e8f52254c2..e8590595b9d614 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx @@ -43,12 +43,12 @@ describe('action_type_form', () => { id: '.pagerduty', iconClass: 'test', selectMessage: 'test', - 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, @@ -92,12 +92,12 @@ describe('action_type_form', () => { id: '.pagerduty', iconClass: 'test', selectMessage: 'test', - 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, @@ -220,7 +220,6 @@ function getActionTypeForm( onAddConnector={onAddConnector ?? jest.fn()} onDeleteAction={onDeleteAction ?? jest.fn()} onConnectorSelected={onConnectorSelected ?? jest.fn()} - actionParamsErrors={{ errors: { summary: [], timestamp: [], dedupKey: [] } }} defaultActionGroupId={defaultActionGroupId ?? 'default'} setActionParamsProperty={jest.fn()} index={index ?? 1} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 2690aeaffad324..526d899b7efb16 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -47,9 +47,6 @@ import { DefaultActionParams } from '../../lib/get_defaults_for_action_params'; export type ActionTypeFormProps = { actionItem: AlertAction; actionConnector: ActionConnector; - actionParamsErrors: { - errors: IErrorObject; - }; index: number; onAddConnector: () => void; onConnectorSelected: (id: string) => void; @@ -80,7 +77,6 @@ const preconfiguredMessage = i18n.translate( export const ActionTypeForm = ({ actionItem, actionConnector, - actionParamsErrors, index, onAddConnector, onConnectorSelected, @@ -106,6 +102,9 @@ export const ActionTypeForm = ({ const selectedActionGroup = actionGroups?.find(({ id }) => id === actionItem.group) ?? defaultActionGroup; const [actionGroup, setActionGroup] = useState(); + const [actionParamsErrors, setActionParamsErrors] = useState<{ errors: IErrorObject }>({ + errors: {}, + }); useEffect(() => { setAvailableActionVariables( @@ -130,6 +129,16 @@ export const ActionTypeForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionGroup]); + useEffect(() => { + (async () => { + const res: { errors: IErrorObject } = await actionTypeRegistry + .get(actionItem.actionTypeId) + ?.validateParams(actionItem.params); + setActionParamsErrors(res); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionItem]); + const canSave = hasSaveActionsCapability(capabilities); const getSelectedOptions = (actionItemId: string) => { const selectedConnector = connectors.find((connector) => connector.id === actionItemId); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 9a011823612c4d..e15916138af717 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -40,12 +40,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -77,12 +77,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -114,12 +114,12 @@ describe('connector_add_flyout', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index fedb2ed3829948..8dbe5f105a0f70 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -198,12 +198,12 @@ function createActionType() { id: `my-action-type-${++count}`, iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index d3a6d662720ca9..1a3a186d891cc3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState, useReducer } from 'react'; +import React, { useCallback, useState, useReducer, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -30,7 +30,9 @@ import { ActionType, ActionConnector, UserConfiguredActionConnector, + IErrorObject, ConnectorAddFlyoutProps, + ActionTypeModel, } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; @@ -38,6 +40,7 @@ import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; import { createConnectorReducer, InitialConnector, ConnectorReducer } from './connector_reducer'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; const ConnectorAddFlyout: React.FunctionComponent = ({ onClose, @@ -47,7 +50,9 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ consumer, actionTypeRegistry, }) => { - let hasErrors = false; + const [hasErrors, setHasErrors] = useState(true); + let actionTypeModel: ActionTypeModel | undefined; + const { http, notifications: { toasts }, @@ -55,7 +60,17 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } = useKibana().services; const [actionType, setActionType] = useState(undefined); const [hasActionsUpgradeableByTrial, setHasActionsUpgradeableByTrial] = useState(false); - + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); // hooks const initialConnector: InitialConnector, Record> = { actionTypeId: actionType?.id ?? '', @@ -73,6 +88,24 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ Record >, }); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + (async () => { + if (actionTypeModel) { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connector, actionType]); const setActionProperty = ( key: Key, @@ -101,7 +134,6 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } let currentForm; - let actionTypeModel; let saveButton; if (!actionType) { currentForm = ( @@ -115,22 +147,12 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ } else { actionTypeModel = actionTypeRegistry.get(actionType.id); - const { - configErrors, - connectorBaseErrors, - connectorErrors, - secretsErrors, - } = getConnectorErrors(connector, actionTypeModel); - hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - currentForm = ( @@ -170,9 +192,9 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; @@ -235,13 +257,13 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ - {actionTypeModel && actionTypeModel.iconClass ? ( + {!!actionTypeModel && actionTypeModel.iconClass ? ( ) : null} - {actionTypeModel && actionType ? ( + {!!actionTypeModel && actionType ? ( <>

@@ -280,7 +302,17 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ ) } > - {currentForm} + <> + {currentForm} + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + @@ -314,7 +346,7 @@ const ConnectorAddFlyout: React.FunctionComponent = ({ - {canSave && actionTypeModel && actionType ? saveButton : null} + {canSave && !!actionTypeModel && actionType ? saveButton : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index c18f6955d12177..1ae37cf96cd3ee 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -39,12 +39,12 @@ describe('connector_add_modal', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index d01ee08df23946..1e9669d1995dda 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo, useReducer, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiModal, @@ -19,6 +19,7 @@ import { EuiFlexItem, EuiIcon, EuiFlexGroup, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ActionConnectorForm, getConnectorErrors } from './action_connector_form'; @@ -31,9 +32,11 @@ import { ActionConnector, ActionTypeRegistryContract, UserConfiguredActionConnector, + IErrorObject, } from '../../../types'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type ConnectorAddModalProps = { @@ -56,7 +59,7 @@ const ConnectorAddModal = ({ notifications: { toasts }, application: { capabilities }, } = useKibana().services; - let hasErrors = false; + const [hasErrors, setHasErrors] = useState(true); const initialConnector: InitialConnector< Record, Record @@ -69,6 +72,7 @@ const ConnectorAddModal = ({ [actionType.id] ); const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); const canSave = hasSaveActionsCapability(capabilities); const reducer: ConnectorReducer< @@ -81,6 +85,34 @@ const ConnectorAddModal = ({ Record >, }); + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); + + const actionTypeModel = actionTypeRegistry.get(actionType.id); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + })(); + }, [connector, actionTypeModel]); + const setConnector = (value: any) => { dispatch({ command: { type: 'setConnector' }, payload: { key: 'connector', value } }); }; @@ -97,15 +129,6 @@ const ConnectorAddModal = ({ onClose(); }, [initialConnector, onClose]); - const actionTypeModel = actionTypeRegistry.get(actionType.id); - const { configErrors, connectorBaseErrors, connectorErrors, secretsErrors } = getConnectorErrors( - connector, - actionTypeModel - ); - hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - const onActionConnectorSave = async (): Promise => await createActionConnector({ http, connector }) .then((savedConnector) => { @@ -157,15 +180,25 @@ const ConnectorAddModal = ({ - + <> + + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + @@ -189,9 +222,9 @@ const ConnectorAddModal = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx index 56bf57cb450956..e6d3c0bde81137 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -51,12 +51,12 @@ describe('connector_edit_flyout', () => { id: 'test-action-type-id', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); @@ -95,12 +95,12 @@ describe('connector_edit_flyout', () => { id: 'test-action-type-id', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 66a4dcc452c518..ca729f9a616629 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useReducer, useState } from 'react'; +import React, { useCallback, useReducer, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -23,6 +23,7 @@ import { EuiLink, EuiTabs, EuiTab, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Option, none, some } from 'fp-ts/lib/Option'; @@ -31,6 +32,7 @@ import { TestConnectorForm } from './test_connector_form'; import { ActionConnector, ConnectorEditFlyoutProps, + IErrorObject, EditConectorTabs, UserConfiguredActionConnector, } from '../../../types'; @@ -44,6 +46,7 @@ import { import './connector_edit_flyout.scss'; import { useKibana } from '../../../common/lib/kibana'; import { getConnectorWithInvalidatedFields } from '../../lib/value_validators'; +import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; const ConnectorEditFlyout = ({ initialConnector, @@ -53,12 +56,14 @@ const ConnectorEditFlyout = ({ consumer, actionTypeRegistry, }: ConnectorEditFlyoutProps) => { + const [hasErrors, setHasErrors] = useState(true); const { http, notifications: { toasts }, docLinks, application: { capabilities }, } = useKibana().services; + const getConnectorWithoutSecrets = () => ({ ...(initialConnector as UserConfiguredActionConnector< Record, @@ -75,6 +80,35 @@ const ConnectorEditFlyout = ({ const [{ connector }, dispatch] = useReducer(reducer, { connector: getConnectorWithoutSecrets(), }); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState<{ + configErrors: IErrorObject; + connectorBaseErrors: IErrorObject; + connectorErrors: IErrorObject; + secretsErrors: IErrorObject; + }>({ + configErrors: {}, + connectorBaseErrors: {}, + connectorErrors: {}, + secretsErrors: {}, + }); + + const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getConnectorErrors(connector, actionTypeModel); + setHasErrors( + !!Object.keys(res.connectorErrors).find( + (errorKey) => (res.connectorErrors as IErrorObject)[errorKey].length >= 1 + ) + ); + setIsLoading(false); + setErrors({ ...res }); + })(); + }, [connector, actionTypeModel]); + const [isSaving, setIsSaving] = useState(false); const [selectedTab, setTab] = useState(tab); @@ -113,25 +147,6 @@ const ConnectorEditFlyout = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [onClose]); - const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); - const { - configErrors, - connectorBaseErrors, - connectorErrors, - secretsErrors, - } = !connector.isPreconfigured - ? getConnectorErrors(connector, actionTypeModel) - : { - configErrors: {}, - connectorBaseErrors: {}, - connectorErrors: {}, - secretsErrors: {}, - }; - - const hasErrors = !!Object.keys(connectorErrors).find( - (errorKey) => connectorErrors[errorKey].length >= 1 - ); - const onActionConnectorSave = async (): Promise => await updateActionConnector({ http, connector, id: connector.id }) .then((savedConnector) => { @@ -227,9 +242,9 @@ const ConnectorEditFlyout = ({ setConnector( getConnectorWithInvalidatedFields( connector, - configErrors, - secretsErrors, - connectorBaseErrors + errors.configErrors, + errors.secretsErrors, + errors.connectorBaseErrors ) ); return; @@ -286,19 +301,29 @@ const ConnectorEditFlyout = ({ {selectedTab === EditConectorTabs.Configuration ? ( !connector.isPreconfigured ? ( - { - setHasChanges(true); - // if the user changes the connector, "forget" the last execution - // so the user comes back to a clean form ready to run a fresh test - setTestExecutionResult(none); - dispatch(changes); - }} - actionTypeRegistry={actionTypeRegistry} - consumer={consumer} - /> + <> + { + setHasChanges(true); + // if the user changes the connector, "forget" the last execution + // so the user comes back to a clean form ready to run a fresh test + setTestExecutionResult(none); + dispatch(changes); + }} + actionTypeRegistry={actionTypeRegistry} + consumer={consumer} + /> + {isLoading ? ( + <> + + {' '} + + ) : ( + <> + )} + ) : ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx index 5cdc15ab0375d1..ae15670ce8ab9f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx @@ -53,12 +53,12 @@ const actionType = { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - 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/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx index 92a17a2e4cfae8..242c1c33d8d795 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Suspense } from 'react'; +import React, { Suspense, useEffect, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -46,11 +46,18 @@ export const TestConnectorForm = ({ isExecutingAction, actionTypeRegistry, }: ConnectorAddFlyoutProps) => { + const [actionErrors, setActionErrors] = useState({}); + const [hasErrors, setHasErrors] = useState(false); const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); const ParamsFieldsComponent = actionTypeModel.actionParamsFields; - const actionErrors = actionTypeModel?.validateParams(actionParams).errors as IErrorObject; - const hasErrors = !!Object.values(actionErrors).find((errors) => errors.length > 0); + useEffect(() => { + (async () => { + const res = (await actionTypeModel?.validateParams(actionParams)).errors as IErrorObject; + setActionErrors({ ...res }); + setHasErrors(!!Object.values(res).find((errors) => errors.length > 0)); + })(); + }, [actionTypeModel, actionParams]); const steps = [ { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 7b6453e705ec38..90eadaf5f9b8b9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -162,12 +162,12 @@ describe('actions_connectors_list component with items', () => { id: 'test', iconClass: 'test', selectMessage: 'test', - 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/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index cb43c168aa999d..b40b7cbc1a3878 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -135,12 +135,12 @@ describe('alert_add', () => { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index a40f77998d6ee4..2d111d54052302 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -15,9 +15,10 @@ import { AlertTypeParams, AlertUpdates, AlertFlyoutCloseReason, + IErrorObject, AlertAddProps, } from '../../../types'; -import { AlertForm, getAlertErrors, isValidAlert } from './alert_form'; +import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form'; import { alertReducer, InitialAlert, InitialAlertReducer } from './alert_reducer'; import { createAlert } from '../../lib/alert_api'; import { HealthCheck } from '../../components/health_check'; @@ -102,6 +103,18 @@ const AlertAdd = ({ } }, [alert.params, initialAlertParams, setInitialAlertParams]); + const [alertActionsErrors, setAlertActionsErrors] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getAlertActionErrors(alert as Alert, actionTypeRegistry); + setIsLoading(false); + setAlertActionsErrors([...res]); + })(); + }, [alert, actionTypeRegistry]); + const checkForChangesAndCloseFlyout = () => { if ( hasAlertChanged(alert, initialAlert, false) || @@ -125,9 +138,8 @@ const AlertAdd = ({ }; const alertType = alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null; - const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( + const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( alert as Alert, - actionTypeRegistry, alertType ); @@ -195,9 +207,10 @@ const AlertAdd = ({ { setIsSaving(true); - if (!isValidAlert(alert, alertErrors, alertActionsErrors)) { + if (isLoading || !isValidAlert(alert, alertErrors, alertActionsErrors)) { setAlert( getAlertWithInvalidatedFields( alert as Alert, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx index fe4b9d066429db..ee36257dedf0b6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add_footer.tsx @@ -13,17 +13,25 @@ import { EuiFlexItem, EuiButtonEmpty, EuiButton, + EuiLoadingSpinner, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useHealthContext } from '../../context/health_context'; interface AlertAddFooterProps { isSaving: boolean; + isFormLoading: boolean; onSave: () => void; onCancel: () => void; } -export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterProps) => { +export const AlertAddFooter = ({ + isSaving, + onSave, + onCancel, + isFormLoading, +}: AlertAddFooterProps) => { const { loadingHealthCheck } = useHealthContext(); return ( @@ -36,6 +44,14 @@ export const AlertAddFooter = ({ isSaving, onSave, onCancel }: AlertAddFooterPro })} + {isFormLoading ? ( + + + + + ) : ( + <> + )} { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx index f6569f32088eec..bf6f0ef43b8200 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useReducer, useState } from 'react'; +import React, { useReducer, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiTitle, @@ -20,11 +20,12 @@ import { EuiPortal, EuiCallOut, EuiSpacer, + EuiLoadingSpinner, } from '@elastic/eui'; import { cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Alert, AlertEditProps, AlertFlyoutCloseReason } from '../../../types'; -import { AlertForm, getAlertErrors, isValidAlert } from './alert_form'; +import { Alert, AlertFlyoutCloseReason, AlertEditProps, IErrorObject } from '../../../types'; +import { AlertForm, getAlertActionErrors, getAlertErrors, isValidAlert } from './alert_form'; import { alertReducer, ConcreteAlertReducer } from './alert_reducer'; import { updateAlert } from '../../lib/alert_api'; import { HealthCheck } from '../../components/health_check'; @@ -53,6 +54,8 @@ export const AlertEdit = ({ false ); const [isConfirmAlertCloseModalOpen, setIsConfirmAlertCloseModalOpen] = useState(false); + const [alertActionsErrors, setAlertActionsErrors] = useState([]); + const [isLoading, setIsLoading] = useState(false); const { http, @@ -64,9 +67,17 @@ export const AlertEdit = ({ const alertType = alertTypeRegistry.get(alert.alertTypeId); - const { alertActionsErrors, alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( + useEffect(() => { + (async () => { + setIsLoading(true); + const res = await getAlertActionErrors(alert as Alert, actionTypeRegistry); + setAlertActionsErrors([...res]); + setIsLoading(false); + })(); + }, [alert, actionTypeRegistry]); + + const { alertBaseErrors, alertErrors, alertParamsErrors } = getAlertErrors( alert as Alert, - actionTypeRegistry, alertType ); @@ -80,7 +91,11 @@ export const AlertEdit = ({ async function onSaveAlert(): Promise { try { - if (isValidAlert(alert, alertErrors, alertActionsErrors) && !hasActionsWithBrokenConnector) { + if ( + !isLoading && + isValidAlert(alert, alertErrors, alertActionsErrors) && + !hasActionsWithBrokenConnector + ) { const newAlert = await updateAlert({ http, alert, id: alert.id }); toasts.addSuccess( i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', { @@ -177,6 +192,14 @@ export const AlertEdit = ({ )} + {isLoading ? ( + + + + + ) : ( + <> + )} { id: 'my-action-type', iconClass: 'test', selectMessage: 'test', - validateConnector: (): ConnectorValidationResult => { - return { + validateConnector: (): Promise> => { + return Promise.resolve({ config: { errors: {}, }, secrets: { errors: {}, }, - }; + }); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index b4b6477fd59470..16878abc362d02 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -121,11 +121,7 @@ export function validateBaseProperties(alertObject: InitialAlert): ValidationRes return validationResult; } -export function getAlertErrors( - alert: Alert, - actionTypeRegistry: ActionTypeRegistryContract, - alertTypeModel: AlertTypeModel | null -) { +export function getAlertErrors(alert: Alert, alertTypeModel: AlertTypeModel | null) { const alertParamsErrors: IErrorObject = alertTypeModel ? alertTypeModel.validate(alert.params).errors : []; @@ -135,18 +131,26 @@ export function getAlertErrors( ...alertBaseErrors, } as IErrorObject; - const alertActionsErrors = alert.actions.map((alertAction: AlertAction) => { - return actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params) - .errors; - }); return { alertParamsErrors, alertBaseErrors, - alertActionsErrors, alertErrors, }; } +export async function getAlertActionErrors( + alert: Alert, + actionTypeRegistry: ActionTypeRegistryContract +): Promise { + return await Promise.all( + alert.actions.map( + async (alertAction: AlertAction) => + (await actionTypeRegistry.get(alertAction.actionTypeId)?.validateParams(alertAction.params)) + .errors + ) + ); +} + export const hasObjectErrors: (errors: IErrorObject) => boolean = (errors) => !!Object.values(errors).find((errorList) => { if (isObject(errorList)) return hasObjectErrors(errorList as IErrorObject); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts index 8c7876c3f7255e..ee561a65069e50 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts @@ -42,12 +42,12 @@ const getTestActionType = ( id: id || 'my-action-type', iconClass: iconClass || 'test', selectMessage: selectedMessage || 'test', - validateConnector: (): ConnectorValidationResult => { - return {}; + validateConnector: (): Promise> => { + return Promise.resolve({}); }, - validateParams: (): GenericValidationResult => { + validateParams: (): Promise> => { const validationResult = { errors: {} }; - return validationResult; + return Promise.resolve(validationResult); }, actionConnectorFields: null, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 0f2b961b1f2da8..5ddddcb73a843c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -109,10 +109,10 @@ export interface ActionTypeModel - ) => ConnectorValidationResult, Partial>; + ) => Promise, Partial>>; validateParams: ( actionParams: ActionParams - ) => GenericValidationResult | unknown>; + ) => Promise | unknown>>; actionConnectorFields: React.LazyExoticComponent< ComponentType< ActionConnectorFieldsProps> From f367deca48a95ca017e98bbf9dba68c646187fc9 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 3 Jun 2021 08:59:22 +0200 Subject: [PATCH 27/77] [Exploratory View] Refactor series storage (#100571) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../exploratory_view/configurations/utils.ts | 2 +- .../exploratory_view.test.tsx | 22 ++-- .../exploratory_view/exploratory_view.tsx | 19 ++- .../exploratory_view/header/header.test.tsx | 13 +- .../shared/exploratory_view/header/header.tsx | 6 +- .../hooks/use_lens_attributes.ts | 6 +- .../hooks/use_series_filters.ts | 6 +- ...url_storage.tsx => use_series_storage.tsx} | 121 +++++++++++------- .../shared/exploratory_view/index.tsx | 26 ++-- .../shared/exploratory_view/rtl_helpers.tsx | 58 ++++----- .../columns/chart_types.test.tsx | 12 +- .../series_builder/columns/chart_types.tsx | 6 +- .../columns/data_types_col.test.tsx | 16 +-- .../series_builder/columns/data_types_col.tsx | 5 +- .../columns/operation_type_select.test.tsx | 26 ++-- .../columns/operation_type_select.tsx | 6 +- .../columns/report_breakdowns.test.tsx | 17 +-- .../columns/report_definition_col.test.tsx | 22 ++-- .../columns/report_definition_col.tsx | 8 +- .../columns/report_definition_field.tsx | 6 +- .../columns/report_filters.test.tsx | 5 +- .../columns/report_types_col.test.tsx | 22 ++-- .../columns/report_types_col.tsx | 9 +- .../series_builder/custom_report_field.tsx | 6 +- .../series_builder/series_builder.tsx | 11 +- .../series_date_picker/index.tsx | 6 +- .../series_date_picker.test.tsx | 41 +++--- .../series_editor/columns/breakdowns.test.tsx | 13 +- .../series_editor/columns/breakdowns.tsx | 6 +- .../columns/filter_expanded.test.tsx | 22 ++-- .../series_editor/columns/filter_expanded.tsx | 6 +- .../columns/filter_value_btn.test.tsx | 3 +- .../columns/filter_value_btn.tsx | 6 +- .../series_editor/columns/remove_series.tsx | 4 +- .../series_editor/columns/series_actions.tsx | 5 +- .../series_editor/columns/series_filter.tsx | 5 +- .../series_editor/selected_filters.test.tsx | 8 +- .../series_editor/selected_filters.tsx | 6 +- .../series_editor/series_editor.tsx | 4 +- 39 files changed, 332 insertions(+), 259 deletions(-) rename x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/{use_url_storage.tsx => use_series_storage.tsx} (51%) diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts index 0d79f76be341c4..fc60800bc4403e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ import rison, { RisonValue } from 'rison-node'; -import type { AllSeries, AllShortSeries } from '../hooks/use_url_storage'; +import type { AllSeries, AllShortSeries } from '../hooks/use_series_storage'; import type { SeriesUrl } from '../types'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx index cf51c4614e543f..fc0062694e0a32 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/dom'; -import { render, mockUrlStorage, mockCore, mockAppIndexPattern } from './rtl_helpers'; +import { render, mockCore, mockAppIndexPattern } from './rtl_helpers'; import { ExploratoryView } from './exploratory_view'; import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/test_utils'; import * as obsvInd from './utils/observability_index_patterns'; @@ -41,26 +41,26 @@ describe('ExploratoryView', () => { it('renders exploratory view', async () => { render(); - await waitFor(() => { - screen.getByText(/open in lens/i); - screen.getByRole('heading', { name: /analyze data/i }); - }); + expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); + expect( + await screen.findByRole('heading', { name: /Performance Distribution/i }) + ).toBeInTheDocument(); }); it('renders lens component when there is series', async () => { - mockUrlStorage({ + const initSeries = { data: { 'ux-series': { - dataType: 'ux', - reportType: 'pld', - breakdown: 'user_agent.name', + dataType: 'ux' as const, + reportType: 'pld' as const, + breakdown: 'user_agent .name', reportDefinitions: { 'service.name': ['elastic-co'] }, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); expect(await screen.findByText('Performance Distribution')).toBeInTheDocument(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index 19136cda6387c5..7958dca6e396ee 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { ExploratoryViewHeader } from './header/header'; -import { useUrlStorage } from './hooks/use_url_storage'; +import { useSeriesStorage } from './hooks/use_series_storage'; import { useLensAttributes } from './hooks/use_lens_attributes'; import { EmptyView } from './components/empty_view'; import { TypedLensByValueInput } from '../../../../../lens/public'; @@ -19,7 +19,11 @@ import { useAppIndexPatternContext } from './hooks/use_app_index_pattern'; import { ReportToDataTypeMap } from './configurations/constants'; import { SeriesBuilder } from './series_builder/series_builder'; -export function ExploratoryView() { +export function ExploratoryView({ + saveAttributes, +}: { + saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; +}) { const { services: { lens, notifications }, } = useKibana(); @@ -28,6 +32,7 @@ export function ExploratoryView() { const wrapperRef = useRef(null); const [height, setHeight] = useState('100vh'); + const [seriesId, setSeriesId] = useState(''); const [lensAttributes, setLensAttributes] = useState( null @@ -37,7 +42,11 @@ export function ExploratoryView() { const LensComponent = lens?.EmbeddableComponent; - const { firstSeriesId: seriesId, firstSeries: series, setSeries } = useUrlStorage(); + const { firstSeriesId, firstSeries: series, setSeries, allSeries } = useSeriesStorage(); + + useEffect(() => { + setSeriesId(firstSeriesId); + }, [allSeries, firstSeriesId]); const lensAttributesT = useLensAttributes({ seriesId, @@ -59,6 +68,10 @@ export function ExploratoryView() { useEffect(() => { setLensAttributes(lensAttributesT); + if (saveAttributes) { + saveAttributes(lensAttributesT); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(lensAttributesT ?? {}), series?.reportType, series?.time?.from]); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx index dec69dc0a7b33e..ca9f2c9e73eb8b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mockUrlStorage, render } from '../rtl_helpers'; +import { render } from '../rtl_helpers'; import { ExploratoryViewHeader } from './header'; import { fireEvent } from '@testing-library/dom'; @@ -22,22 +22,23 @@ describe('ExploratoryViewHeader', function () { }); it('should be able to click open in lens', function () { - mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; const { getByText, core } = render( + />, + { initSeries } ); fireEvent.click(getByText('Open in Lens')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 8f2f30185d37fe..3265287a7f915c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -12,7 +12,7 @@ import { TypedLensByValueInput } from '../../../../../../lens/public'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../plugin'; import { DataViewLabels } from '../configurations/constants'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; interface Props { seriesId: string; @@ -24,7 +24,9 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { services: { lens }, } = useKibana(); - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); return ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index ea6f4354604018..4e9c360745b6b3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { isEmpty } from 'lodash'; import { TypedLensByValueInput } from '../../../../../../lens/public'; import { LensAttributes } from '../configurations/lens_attributes'; -import { useUrlStorage } from './use_url_storage'; +import { useSeriesStorage } from './use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DataSeries, SeriesUrl, UrlFilter } from '../types'; @@ -40,8 +40,8 @@ export const getFiltersFromDefs = ( export const useLensAttributes = ({ seriesId, }: Props): TypedLensByValueInput['attributes'] | null => { - const { series } = useUrlStorage(seriesId); - + const { getSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const { breakdown, seriesType, operationType, reportType, reportDefinitions = {} } = series ?? {}; const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts index 2605818ed7846c..2d2618bc46152c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useUrlStorage } from './use_url_storage'; +import { useSeriesStorage } from './use_series_storage'; import { UrlFilter } from '../types'; export interface UpdateFilter { @@ -15,7 +15,9 @@ export interface UpdateFilter { } export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const filters = series.filters ?? []; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx similarity index 51% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx index 498886cc944103..fac75f910a93fc 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_url_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx @@ -5,8 +5,11 @@ * 2.0. */ -import React, { createContext, useContext, Context } from 'react'; -import { IKbnUrlStateStorage } from '../../../../../../../../src/plugins/kibana_utils/public'; +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { + IKbnUrlStateStorage, + ISessionStorageStateStorage, +} from '../../../../../../../../src/plugins/kibana_utils/public'; import type { AppDataType, ReportViewTypeId, @@ -18,17 +21,81 @@ import { convertToShortUrl } from '../configurations/utils'; import { OperationType, SeriesType } from '../../../../../../lens/public'; import { URL_KEYS } from '../configurations/constants/url_constants'; -export const UrlStorageContext = createContext(null); +export interface SeriesContextValue { + firstSeries: SeriesUrl; + firstSeriesId: string; + allSeriesIds: string[]; + allSeries: AllSeries; + setSeries: (seriesIdN: string, newValue: SeriesUrl) => void; + getSeries: (seriesId: string) => SeriesUrl; + removeSeries: (seriesId: string) => void; +} +export const UrlStorageContext = createContext({} as SeriesContextValue); interface ProviderProps { - storage: IKbnUrlStateStorage; + storage: IKbnUrlStateStorage | ISessionStorageStateStorage; } export function UrlStorageContextProvider({ children, storage, }: ProviderProps & { children: JSX.Element }) { - return {children}; + const allSeriesKey = 'sr'; + + const [allShortSeries, setAllShortSeries] = useState( + () => storage.get(allSeriesKey) ?? {} + ); + const [allSeries, setAllSeries] = useState({}); + const [firstSeriesId, setFirstSeriesId] = useState(''); + + useEffect(() => { + const allSeriesIds = Object.keys(allShortSeries); + const allSeriesN: AllSeries = {}; + allSeriesIds.forEach((seriesKey) => { + allSeriesN[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]); + }); + + setAllSeries(allSeriesN); + setFirstSeriesId(allSeriesIds?.[0]); + (storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries); + }, [allShortSeries, storage]); + + const setSeries = (seriesIdN: string, newValue: SeriesUrl) => { + setAllShortSeries((prevState) => { + prevState[seriesIdN] = convertToShortUrl(newValue); + return { ...prevState }; + }); + }; + + const removeSeries = (seriesIdN: string) => { + delete allShortSeries[seriesIdN]; + delete allSeries[seriesIdN]; + }; + + const allSeriesIds = Object.keys(allShortSeries); + + const getSeries = useCallback( + (seriesId?: string) => { + return seriesId ? allSeries?.[seriesId] ?? {} : ({} as SeriesUrl); + }, + [allSeries] + ); + + const value = { + storage, + getSeries, + setSeries, + removeSeries, + firstSeriesId, + allSeries, + allSeriesIds, + firstSeries: allSeries?.[firstSeriesId], + }; + return {children}; +} + +export function useSeriesStorage() { + return useContext(UrlStorageContext); } function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { @@ -64,47 +131,3 @@ export type AllShortSeries = Record; export type AllSeries = Record; export const NEW_SERIES_KEY = 'new-series-key'; - -export function useUrlStorage(seriesId?: string) { - const allSeriesKey = 'sr'; - const storage = useContext((UrlStorageContext as unknown) as Context); - let series: SeriesUrl = {} as SeriesUrl; - const allShortSeries = storage.get(allSeriesKey) ?? {}; - - const allSeriesIds = Object.keys(allShortSeries); - - const allSeries: AllSeries = {}; - - allSeriesIds.forEach((seriesKey) => { - allSeries[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]); - }); - - if (seriesId) { - series = allSeries?.[seriesId] ?? ({} as SeriesUrl); - } - - const setSeries = async (seriesIdN: string, newValue: SeriesUrl) => { - allShortSeries[seriesIdN] = convertToShortUrl(newValue); - allSeries[seriesIdN] = newValue; - return storage.set(allSeriesKey, allShortSeries); - }; - - const removeSeries = (seriesIdN: string) => { - delete allShortSeries[seriesIdN]; - delete allSeries[seriesIdN]; - storage.set(allSeriesKey, allShortSeries); - }; - - const firstSeriesId = allSeriesIds?.[0]; - - return { - storage, - setSeries, - removeSeries, - series, - firstSeriesId, - allSeries, - allSeriesIds, - firstSeries: allSeries?.[firstSeriesId], - }; -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx index 80b6b29f883030..3de29b02853e8c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx @@ -17,11 +17,19 @@ import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; import { createKbnUrlStateStorage, withNotifyOnErrors, + createSessionStorageStateStorage, } from '../../../../../../../src/plugins/kibana_utils/public/'; -import { UrlStorageContextProvider } from './hooks/use_url_storage'; +import { UrlStorageContextProvider } from './hooks/use_series_storage'; import { useTrackPageview } from '../../..'; +import { TypedLensByValueInput } from '../../../../../lens/public'; -export function ExploratoryViewPage() { +export function ExploratoryViewPage({ + saveAttributes, + useSessionStorage = false, +}: { + useSessionStorage?: boolean; + saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; +}) { useTrackPageview({ app: 'observability-overview', path: 'exploratory-view' }); useTrackPageview({ app: 'observability-overview', path: 'exploratory-view', delay: 15000 }); @@ -39,17 +47,19 @@ export function ExploratoryViewPage() { const history = useHistory(); - const kbnUrlStateStorage = createKbnUrlStateStorage({ - history, - useHash: uiSettings!.get('state:storeInSessionStorage'), - ...withNotifyOnErrors(notifications!.toasts), - }); + const kbnUrlStateStorage = useSessionStorage + ? createSessionStorageStateStorage() + : createKbnUrlStateStorage({ + history, + useHash: uiSettings!.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(notifications!.toasts), + }); return ( - + diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx index beb1daafbd55ff..9118e49a42dfb5 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx @@ -16,29 +16,23 @@ import { CoreStart } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n/react'; import { coreMock } from 'src/core/public/mocks'; import { - KibanaServices, KibanaContextProvider, + KibanaServices, } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { lensPluginMock } from '../../../../../lens/public/mocks'; +import * as useAppIndexPatternHook from './hooks/use_app_index_pattern'; import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; -import { AllSeries, UrlStorageContextProvider } from './hooks/use_url_storage'; -import { - withNotifyOnErrors, - createKbnUrlStateStorage, -} from '../../../../../../../src/plugins/kibana_utils/public'; +import { AllSeries, UrlStorageContext } from './hooks/use_series_storage'; + import * as fetcherHook from '../../../hooks/use_fetcher'; -import * as useUrlHook from './hooks/use_url_storage'; import * as useSeriesFilterHook from './hooks/use_series_filters'; import * as useHasDataHook from '../../../hooks/use_has_data'; import * as useValuesListHook from '../../../hooks/use_values_list'; -import * as useAppIndexPatternHook from './hooks/use_app_index_pattern'; - // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/index_patterns/index_pattern.stub'; import indexPatternData from './configurations/test_data/test_index_pattern.json'; - // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { setIndexPatterns } from '../../../../../../../src/plugins/data/public/services'; import { IndexPatternsContract } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; @@ -73,6 +67,11 @@ interface RenderRouterOptions extends KibanaProviderOptions; url?: Url; + initSeries?: { + data?: AllSeries; + filters?: UrlFilter[]; + breakdown?: string; + }; } function getSetting(key: string): T { @@ -127,17 +126,8 @@ export const mockCore: () => Partial>({ children, core, - history, kibanaProps, }: MockKibanaProviderProps) { - const { notifications } = core!; - - const kbnUrlStateStorage = createKbnUrlStateStorage({ - history, - useHash: false, - ...withNotifyOnErrors(notifications!.toasts), - }); - const indexPattern = mockIndexPattern; setIndexPatterns(({ @@ -149,11 +139,7 @@ export function MockKibanaProvider>({ - - - {children} - - + {children} @@ -184,6 +170,7 @@ export function render( kibanaProps, renderOptions, url, + initSeries = {}, }: RenderRouterOptions = {} ) { if (url) { @@ -195,15 +182,20 @@ export function render( ...customCore, }; + const seriesContextValue = mockSeriesStorageContext(initSeries); + return { ...reactTestLibRender( - {ui} + + {ui} + , renderOptions ), history, core, + ...seriesContextValue, }; } @@ -256,7 +248,7 @@ export const mockUseValuesList = (values?: string[]) => { return { spy, onRefreshTimeRange }; }; -export const mockUrlStorage = ({ +function mockSeriesStorageContext({ data, filters, breakdown, @@ -264,7 +256,7 @@ export const mockUrlStorage = ({ data?: AllSeries; filters?: UrlFilter[]; breakdown?: string; -}) => { +}) { const mockDataSeries = data || { 'performance-distribution': { reportType: 'pld', @@ -282,18 +274,18 @@ export const mockUrlStorage = ({ const removeSeries = jest.fn(); const setSeries = jest.fn(); - const spy = jest.spyOn(useUrlHook, 'useUrlStorage').mockReturnValue({ + const getSeries = jest.fn().mockReturnValue(series); + + return { firstSeriesId, allSeriesIds, removeSeries, setSeries, - series, + getSeries, firstSeries: mockDataSeries[firstSeriesId], allSeries: mockDataSeries, - } as any); - - return { spy, removeSeries, setSeries }; -}; + }; +} export function mockUseSeriesFilter() { const removeFilter = jest.fn(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx index bac935dbecbe75..c054853d9c8777 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx @@ -7,13 +7,11 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { mockUrlStorage, render } from '../../rtl_helpers'; +import { render } from '../../rtl_helpers'; import { SeriesChartTypesSelect, XYChartTypesSelect } from './chart_types'; describe.skip('SeriesChartTypesSelect', function () { it('should render properly', async function () { - mockUrlStorage({}); - render(); await waitFor(() => { @@ -22,9 +20,9 @@ describe.skip('SeriesChartTypesSelect', function () { }); it('should call set series on change', async function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); await waitFor(() => { screen.getByText(/chart type/i); @@ -44,8 +42,6 @@ describe.skip('SeriesChartTypesSelect', function () { describe('XYChartTypesSelect', function () { it('should render properly', async function () { - mockUrlStorage({}); - render(); await waitFor(() => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx index a296d2520db349..9ae8b68bf3e8c7 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../../plugin'; import { useFetcher } from '../../../../..'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { SeriesType } from '../../../../../../../lens/public'; const CHART_TYPE_LABEL = i18n.translate('xpack.observability.expView.chartTypes.label', { @@ -27,7 +27,9 @@ export function SeriesChartTypesSelect({ seriesTypes?: SeriesType[]; defaultChartType: SeriesType; }) { - const { series, setSeries, allSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, allSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const seriesType = series?.seriesType ?? defaultChartType; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx index 9348fcbe15f6cf..51529a3b1ac175 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, render } from '../../rtl_helpers'; import { dataTypes, DataTypesCol } from './data_types_col'; describe('DataTypesCol', function () { @@ -24,9 +24,7 @@ describe('DataTypesCol', function () { }); it('should set series on change', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render(); fireEvent.click(screen.getByText(/user experience \(rum\)/i)); @@ -35,18 +33,18 @@ describe('DataTypesCol', function () { }); it('should set series on change on already selected', function () { - mockUrlStorage({ + const initSeries = { data: { [seriesId]: { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); const button = screen.getByRole('button', { name: /Synthetic Monitoring/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx index b64fad51e97788..08e7f4ddcd3d05 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx @@ -10,7 +10,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; import { AppDataType } from '../../types'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { ReportToDataTypeMap } from '../../configurations/constants'; export const dataTypes: Array<{ id: AppDataType; label: string }> = [ @@ -22,8 +22,9 @@ export const dataTypes: Array<{ id: AppDataType; label: string }> = [ ]; export function DataTypesCol({ seriesId }: { seriesId: string }) { - const { series, setSeries, removeSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, removeSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const { loading } = useAppIndexPatternContext(); const onDataTypeChange = (dataType?: AppDataType) => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx index 9550b8e98103b7..c262a94f968be0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockUrlStorage, render } from '../../rtl_helpers'; +import { render } from '../../rtl_helpers'; import { OperationTypeSelect } from './operation_type_select'; describe('OperationTypeSelect', function () { @@ -18,35 +18,35 @@ describe('OperationTypeSelect', function () { }); it('should display selected value', function () { - mockUrlStorage({ + const initSeries = { data: { 'performance-distribution': { - dataType: 'ux', - reportType: 'kpi', - operationType: 'median', + dataType: 'ux' as const, + reportType: 'kpi' as const, + operationType: 'median' as const, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + render(, { initSeries }); screen.getByText('Median'); }); it('should call set series on change', function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { 'series-id': { - dataType: 'ux', - reportType: 'kpi', - operationType: 'median', + dataType: 'ux' as const, + reportType: 'kpi' as const, + operationType: 'median' as const, time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + const { setSeries } = render(, { initSeries }); fireEvent.click(screen.getByTestId('operationTypeSelect')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx index 75203d7bae3a0b..fa273f61809355 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSuperSelect } from '@elastic/eui'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { OperationType } from '../../../../../../../lens/public'; export function OperationTypeSelect({ @@ -19,7 +19,9 @@ export function OperationTypeSelect({ seriesId: string; defaultOperationType?: OperationType; }) { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const operationType = series?.operationType; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx index 3363d17d81eab9..f576862f18e76c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx @@ -7,9 +7,8 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { render } from '../../../../../utils/test_helper'; import { getDefaultConfigs } from '../../configurations/default_configs'; -import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; +import { mockIndexPattern, render } from '../../rtl_helpers'; import { ReportBreakdowns } from './report_breakdowns'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; @@ -22,8 +21,6 @@ describe('Series Builder ReportBreakdowns', function () { }); it('should render properly', function () { - mockUrlStorage({}); - render(); screen.getByText('Select an option: , is selected'); @@ -31,9 +28,9 @@ describe('Series Builder ReportBreakdowns', function () { }); it('should set new series breakdown on change', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, @@ -53,9 +50,9 @@ describe('Series Builder ReportBreakdowns', function () { }); }); it('should set undefined on new series on no select breakdown', function () { - const { setSeries } = mockUrlStorage({}); - - render(); + const { setSeries } = render( + + ); const btn = screen.getByRole('button', { name: /select an option: Browser family , is selected/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx index 27adcf4682c023..fdf6633c0ddb52 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx @@ -11,7 +11,6 @@ import { getDefaultConfigs } from '../../configurations/default_configs'; import { mockAppIndexPattern, mockIndexPattern, - mockUrlStorage, mockUseValuesList, render, } from '../../rtl_helpers'; @@ -28,21 +27,23 @@ describe('Series Builder ReportDefinitionCol', function () { indexPattern: mockIndexPattern, }); - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { [seriesId]: { - dataType: 'ux', - reportType: 'pld', + dataType: 'ux' as const, + reportType: 'pld' as const, time: { from: 'now-30d', to: 'now' }, reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, }, }, - }); + }; mockUseValuesList(['elastic-co']); it('should render properly', async function () { - render(); + render(, { + initSeries, + }); screen.getByText('Web Application'); screen.getByText('Environment'); @@ -51,7 +52,9 @@ describe('Series Builder ReportDefinitionCol', function () { }); it('should render selected report definitions', async function () { - render(); + render(, { + initSeries, + }); expect(await screen.findByText('elastic-co')).toBeInTheDocument(); @@ -59,7 +62,10 @@ describe('Series Builder ReportDefinitionCol', function () { }); it('should be able to remove selected definition', async function () { - render(); + const { setSeries } = render( + , + { initSeries } + ); expect( await screen.findByLabelText('Remove elastic-co from selection in this group') diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx index ff8b0f7aa578b7..338f5d52c26fae 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; import styled from 'styled-components'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { CustomReportField } from '../custom_report_field'; import { DataSeries, URLReportDefinition } from '../../types'; import { SeriesChartTypesSelect } from './chart_types'; @@ -38,9 +38,11 @@ export function ReportDefinitionCol({ }) { const { indexPattern } = useAppIndexPatternContext(); - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); - const { reportDefinitions: selectedReportDefinitions = {} } = series; + const series = getSeries(seriesId); + + const { reportDefinitions: selectedReportDefinitions = {} } = series ?? {}; const { reportDefinitions, defaultSeriesType, hasOperationType, yAxisColumns } = dataViewSeries; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx index 9f92bec4d1f9c5..1a6d2af8f4d40d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty } from 'lodash'; import FieldValueSuggestions from '../../../field_value_suggestions'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; import { ESFilter } from '../../../../../../../../../typings/elasticsearch'; import { PersistableFilter } from '../../../../../../../lens/common'; @@ -25,7 +25,9 @@ interface Props { } export function ReportDefinitionField({ seriesId, field, dataSeries, onChange }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx index 1467cb54d648ad..dc2dc629cc121d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx @@ -7,10 +7,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; -import { render } from '../../../../../utils/test_helper'; import { ReportFilters } from './report_filters'; import { getDefaultConfigs } from '../../configurations/default_configs'; -import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers'; +import { mockIndexPattern, render } from '../../rtl_helpers'; describe('Series Builder ReportFilters', function () { const seriesId = 'test-series-id'; @@ -20,7 +19,7 @@ describe('Series Builder ReportFilters', function () { reportType: 'pld', indexPattern: mockIndexPattern, }); - mockUrlStorage({}); + it('should render properly', function () { render(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx index 20c4ea98d482d1..c721a2fa2fe771 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx @@ -7,11 +7,11 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, render } from '../../rtl_helpers'; import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col'; import { ReportTypes } from '../series_builder'; import { DEFAULT_TIME } from '../../configurations/constants'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; +import { NEW_SERIES_KEY } from '../../hooks/use_series_storage'; describe('ReportTypesCol', function () { const seriesId = 'test-series-id'; @@ -30,8 +30,9 @@ describe('ReportTypesCol', function () { }); it('should set series on change', function () { - const { setSeries } = mockUrlStorage({}); - render(); + const { setSeries } = render( + + ); fireEvent.click(screen.getByText(/monitor duration/i)); @@ -46,18 +47,21 @@ describe('ReportTypesCol', function () { }); it('should set selected as filled', function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { [NEW_SERIES_KEY]: { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, }, - }); + }; - render(); + const { setSeries } = render( + , + { initSeries } + ); const button = screen.getByRole('button', { name: /pings histogram/i, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx index bd82d1d1bd5004..9fff8dae14a47c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx @@ -11,7 +11,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import styled from 'styled-components'; import { ReportViewTypeId, SeriesUrl } from '../../types'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { DEFAULT_TIME } from '../../configurations/constants'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; @@ -21,10 +21,9 @@ interface Props { } export function ReportTypesCol({ seriesId, reportTypes }: Props) { - const { - series: { reportType: selectedReportType, ...restSeries }, - setSeries, - } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + + const { reportType: selectedReportType, ...restSeries } = getSeries(seriesId); const { loading, hasData, selectedApp } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx index b41f3a603e5da8..201df9628e1357 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiSuperSelect } from '@elastic/eui'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { ReportDefinition } from '../types'; interface Props { @@ -18,7 +18,9 @@ interface Props { } export function CustomReportField({ field, seriesId, options: opts }: Props) { - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { reportDefinitions: rtd = {} } = series; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx index 1944bb281598bc..32f1fb7f7c43bf 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx @@ -15,7 +15,7 @@ import { ReportTypesCol } from './columns/report_types_col'; import { ReportDefinitionCol } from './columns/report_definition_col'; import { ReportFilters } from './columns/report_filters'; import { ReportBreakdowns } from './columns/report_breakdowns'; -import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; import { getDefaultConfigs } from '../configurations/default_configs'; @@ -53,7 +53,9 @@ export function SeriesBuilder({ seriesId: string; seriesBuilderRef: RefObject; }) { - const { series, setSeries, removeSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries, removeSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { dataType, @@ -156,9 +158,8 @@ export function SeriesBuilder({ reportDefinitions, }; - setSeries(newSeriesId, newSeriesN).then(() => { - removeSeries(NEW_SERIES_KEY); - }); + setSeries(newSeriesId, newSeriesN); + removeSeries(NEW_SERIES_KEY); } }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx index 960c2978287bc8..d6a70532f4257f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx @@ -8,7 +8,7 @@ import { EuiSuperDatePicker } from '@elastic/eui'; import React, { useEffect } from 'react'; import { useHasData } from '../../../../hooks/use_has_data'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { useQuickTimeRanges } from '../../../../hooks/use_quick_time_ranges'; import { DEFAULT_TIME } from '../configurations/constants'; @@ -30,7 +30,9 @@ export function SeriesDatePicker({ seriesId }: Props) { const commonlyUsedRanges = useQuickTimeRanges(); - const { series, setSeries } = useUrlStorage(seriesId); + const { getSeries, setSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); function onTimeChange({ start, end }: { start: string; end: string }) { onRefreshTimeRange(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx index e99b701f091fef..0edc4330ef97aa 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx @@ -6,62 +6,67 @@ */ import React from 'react'; -import { mockUrlStorage, mockUseHasData, render } from '../rtl_helpers'; +import { mockUseHasData, render } from '../rtl_helpers'; import { fireEvent, waitFor } from '@testing-library/react'; import { SeriesDatePicker } from './index'; import { DEFAULT_TIME } from '../configurations/constants'; describe('SeriesDatePicker', function () { it('should render properly', function () { - mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, }, }, - }); - const { getByText } = render(); + }; + const { getByText } = render(, { initSeries }); getByText('Last 30 minutes'); }); it('should set defaults', async function () { - const { setSeries: setSeries1 } = mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - reportType: 'upp', - dataType: 'synthetics', + reportType: 'upp' as const, + dataType: 'synthetics' as const, breakdown: 'monitor.status', }, }, - } as any); - render(); + }; + const { setSeries: setSeries1 } = render( + , + { initSeries: initSeries as any } + ); expect(setSeries1).toHaveBeenCalledTimes(1); expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', { breakdown: 'monitor.status', - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, time: DEFAULT_TIME, }); }); it('should set series data', async function () { - const { setSeries } = mockUrlStorage({ + const initSeries = { data: { 'uptime-pings-histogram': { - dataType: 'synthetics', - reportType: 'upp', + dataType: 'synthetics' as const, + reportType: 'upp' as const, breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, }, }, - }); + }; const { onRefreshTimeRange } = mockUseHasData(); - const { getByTestId } = render(); + const { getByTestId, setSeries } = render(, { + initSeries, + }); await waitFor(function () { fireEvent.click(getByTestId('superDatePickerToggleQuickMenuButton')); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx index 9d26ec79c31ad4..0ce9db73f92b14 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { Breakdowns } from './breakdowns'; -import { mockIndexPattern, mockUrlStorage, render } from '../../rtl_helpers'; -import { NEW_SERIES_KEY } from '../../hooks/use_url_storage'; +import { mockIndexPattern, render } from '../../rtl_helpers'; +import { NEW_SERIES_KEY } from '../../hooks/use_series_storage'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; @@ -21,8 +21,6 @@ describe('Breakdowns', function () { }); it('should render properly', async function () { - mockUrlStorage({}); - render( + />, + { initSeries } ); screen.getAllByText('Operating system'); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx index 5cf6ac47aa8c74..cf24cb31951b19 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { EuiSuperSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { USE_BREAK_DOWN_COLUMN } from '../../configurations/constants'; -import { useUrlStorage } from '../../hooks/use_url_storage'; import { DataSeries } from '../../types'; interface Props { @@ -19,7 +19,9 @@ interface Props { } export function Breakdowns({ reportViewConfig, seriesId, breakdowns = [] }: Props) { - const { setSeries, series } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const selectedBreakdown = series.breakdown; const NO_BREAKDOWN = 'no_breakdown'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx index 8d3060792857e3..1a8c5b335bc4fb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterExpanded } from './filter_expanded'; -import { mockAppIndexPattern, mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockAppIndexPattern, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('FilterExpanded', function () { it('should render properly', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; mockAppIndexPattern(); render( @@ -22,13 +22,14 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={jest.fn()} - /> + />, + { initSeries } ); screen.getByText('Browser Family'); }); it('should call go back on click', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; const goBack = jest.fn(); render( @@ -37,7 +38,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={goBack} - /> + />, + { initSeries } ); fireEvent.click(screen.getByText('Browser Family')); @@ -47,7 +49,7 @@ describe('FilterExpanded', function () { }); it('should call useValuesList on load', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; const { spy } = mockUseValuesList(['Chrome', 'Firefox']); @@ -59,7 +61,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={goBack} - /> + />, + { initSeries } ); expect(spy).toHaveBeenCalledTimes(1); @@ -71,7 +74,7 @@ describe('FilterExpanded', function () { ); }); it('should filter display values', async function () { - mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }); + const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; mockUseValuesList(['Chrome', 'Firefox']); @@ -81,7 +84,8 @@ describe('FilterExpanded', function () { label={'Browser Family'} field={USER_AGENT_NAME} goBack={jest.fn()} - /> + />, + { initSeries } ); expect(screen.queryByText('Firefox')).toBeTruthy(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx index 7a646c9035968a..cc1769cfa8c954 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { rgba } from 'polished'; import { i18n } from '@kbn/i18n'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { UrlFilter } from '../../types'; import { FilterValueButton } from './filter_value_btn'; import { useValuesList } from '../../../../../hooks/use_values_list'; @@ -33,7 +33,9 @@ export function FilterExpanded({ seriesId, field, label, goBack, nestedField, is const [isOpen, setIsOpen] = useState({ value: '', negate: false }); - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { values, loading } = useValuesList({ query: value, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx index befbb3b74d6d72..79eb858b7624b9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { FilterValueButton } from './filter_value_btn'; -import { mockUrlStorage, mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME, USER_AGENT_VERSION, @@ -75,7 +75,6 @@ describe('FilterValueButton', function () { }); }); it('should remove filter on click if already selected', async function () { - mockUrlStorage({}); const { removeFilter } = mockUseSeriesFilter(); render( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx index ccb9c90a884bb2..ea84ec6b6c2129 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { EuiFilterButton, hexToRgb } from '@elastic/eui'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; import { useSeriesFilters } from '../../hooks/use_series_filters'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import FieldValueSuggestions from '../../../field_value_suggestions'; @@ -37,7 +37,9 @@ export function FilterValueButton({ nestedField, allSelectedValues, }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { indexPattern } = useAppIndexPatternContext(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx index ba2cdc545fbeff..dc84352ff3b3da 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx @@ -8,14 +8,14 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { EuiButtonIcon } from '@elastic/eui'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; interface Props { seriesId: string; } export function RemoveSeries({ seriesId }: Props) { - const { removeSeries } = useUrlStorage(); + const { removeSeries } = useSeriesStorage(); const onClick = () => { removeSeries(seriesId); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx index cdc20e2d9ab6c7..5374fc33093a11 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx @@ -9,14 +9,15 @@ import React from 'react'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RemoveSeries } from './remove_series'; -import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../../hooks/use_series_storage'; import { ReportToDataTypeMap } from '../../configurations/constants'; interface Props { seriesId: string; } export function SeriesActions({ seriesId }: Props) { - const { series, removeSeries, setSeries } = useUrlStorage(seriesId); + const { getSeries, removeSeries, setSeries } = useSeriesStorage(); + const series = getSeries(seriesId); const onEdit = () => { removeSeries(seriesId); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx index 926852fda5cbca..9e5770c2de8f94 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx @@ -19,7 +19,7 @@ import { FilterExpanded } from './filter_expanded'; import { DataSeries } from '../../types'; import { FieldLabels } from '../../configurations/constants/constants'; import { SelectedFilters } from '../selected_filters'; -import { useUrlStorage } from '../../hooks/use_url_storage'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; interface Props { seriesId: string; @@ -53,7 +53,8 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P }; }); - const { setSeries, series: urlSeries } = useUrlStorage(seriesId); + const { setSeries, getSeries } = useSeriesStorage(); + const urlSeries = getSeries(seriesId); const button = ( ); + render(, { initSeries }); await waitFor(() => { screen.getByText('Chrome'); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx index aabb39f88507f0..63abb581c9c723 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx @@ -7,7 +7,7 @@ import React, { Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useUrlStorage } from '../hooks/use_url_storage'; +import { useSeriesStorage } from '../hooks/use_series_storage'; import { FilterLabel } from '../components/filter_label'; import { DataSeries, UrlFilter } from '../types'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; @@ -20,7 +20,9 @@ interface Props { isNew?: boolean; } export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) { - const { series } = useUrlStorage(seriesId); + const { getSeries } = useSeriesStorage(); + + const series = getSeries(seriesId); const { reportDefinitions = {} } = series; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx index d883b854c88cb3..6e513fcd2fec90 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -11,7 +11,7 @@ import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { SeriesFilter } from './columns/series_filter'; import { DataSeries } from '../types'; -import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_storage'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { DatePickerCol } from './columns/date_picker_col'; import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; @@ -19,7 +19,7 @@ import { SeriesActions } from './columns/series_actions'; import { ChartEditOptions } from './chart_edit_options'; export function SeriesEditor() { - const { allSeries, firstSeriesId } = useUrlStorage(); + const { allSeries, firstSeriesId } = useSeriesStorage(); const columns = [ { From d4ecee6ba0ceb92fcf965b776139821abfb85975 Mon Sep 17 00:00:00 2001 From: Ashokaditya Date: Thu, 3 Jun 2021 09:22:49 +0200 Subject: [PATCH 28/77] [Security Solution] [Endpoint] Add endpoint details activity log (#99795) * WIP add tabs for endpoint details * fetch activity log for endpoint this is work in progress with dummy data * refactor to hold host details and activity log within endpointDetails * api for fetching actions log * add a selector for getting selected agent id * use the new api to show actions log * review changes * move util function to common/utils in order to use it in endpoint_hosts as well as in trusted _apps review suggestion * use util function to get API path review suggestion * sync url params with details active tab review suggestion * fix types due to merge commit refs 3722552f739f74d3c457e8ed6cf80444aa6dfd06 * use AsyncResourseState type review suggestions * sort entries chronologically with recent at the top * adjust icon sizes within entries to match mocks * remove endpoint list paging stuff (not for now) * fix import after sync with master * make the search bar work (sort of) this needs to be fleshed out in a later PR * add tests to middleware for now * use snake case for naming routes review changes * rename and use own relative time function review change * use euiTheme tokens review change * add a comment review changes * log errors to kibana log and unwind stack review changes * use FleetActionGenerator for mocking data review changes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/endpoint/constants.ts | 3 + .../common/endpoint/schema/actions.ts | 8 ++ .../endpoint/formatted_date_time.tsx | 8 +- .../containers/detection_engine/alerts/api.ts | 2 +- .../public/management/common/utils.test.ts | 37 +++++- .../public/management/common/utils.ts | 5 + .../pages/endpoint_hosts/store/action.ts | 10 +- .../pages/endpoint_hosts/store/builders.ts | 53 ++++++++ .../pages/endpoint_hosts/store/index.test.ts | 13 +- .../endpoint_hosts/store/middleware.test.ts | 66 +++++++++- .../pages/endpoint_hosts/store/middleware.ts | 29 ++++- .../pages/endpoint_hosts/store/reducer.ts | 114 ++++++++++------- .../pages/endpoint_hosts/store/selectors.ts | 55 ++++++++- .../management/pages/endpoint_hosts/types.ts | 20 +-- .../components/endpoint_details_tabs.tsx | 78 ++++++++++++ .../view/details/components/log_entry.tsx | 57 +++++++++ .../view/details/endpoint_activity_log.tsx | 45 +++++++ .../view/details/endpoint_details.tsx | 1 + .../view/details/endpoints.stories.tsx | 111 +++++++++++++++++ .../endpoint_hosts/view/details/index.tsx | 115 +++++++++++++++--- .../pages/endpoint_hosts/view/translations.ts | 23 ++++ .../pages/trusted_apps/service/index.ts | 2 +- .../pages/trusted_apps/service/utils.test.ts | 45 ------- .../pages/trusted_apps/service/utils.ts | 11 -- .../view/trusted_apps_page.test.tsx | 2 +- .../public/management/store/reducer.ts | 8 +- .../endpoint/routes/actions/audit_log.ts | 30 +++++ .../routes/actions/audit_log_handler.ts | 59 +++++++++ .../server/endpoint/routes/actions/index.ts | 1 + .../security_solution/server/plugin.ts | 6 +- 30 files changed, 868 insertions(+), 149 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts delete mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 1c0b09a4648e5f..c85778f2f38fad 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -32,3 +32,6 @@ export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; /** Host Isolation Routes */ export const ISOLATE_HOST_ROUTE = `/api/endpoint/isolate`; export const UNISOLATE_HOST_ROUTE = `/api/endpoint/unisolate`; + +/** Endpoint Actions Log Routes */ +export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index e8997158cdfad8..32affddf462949 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -20,3 +20,11 @@ export const HostIsolationRequestSchema = { comment: schema.maybe(schema.string()), }), }; + +export const EndpointActionLogRequestSchema = { + // TODO improve when using pagination with query params + query: schema.object({}), + params: schema.object({ + agent_id: schema.string(), + }), +}; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx index 2fdb7e99d860e8..740437646f61a4 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/formatted_date_time.tsx @@ -8,10 +8,14 @@ import React from 'react'; import { FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n/react'; -export const FormattedDateAndTime: React.FC<{ date: Date }> = ({ date }) => { +export const FormattedDateAndTime: React.FC<{ date: Date; showRelativeTime?: boolean }> = ({ + date, + showRelativeTime = false, +}) => { // If date is greater than or equal to 1h (ago), then show it as a date + // and if showRelativeTime is false // else, show it as relative to "now" - return Date.now() - date.getTime() >= 3.6e6 ? ( + return Date.now() - date.getTime() >= 3.6e6 && !showRelativeTime ? ( <> {' @'} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index 65185b4d05135b..a7bd42c6af5ee5 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -25,7 +25,7 @@ import { UpdateAlertStatusProps, CasesFromAlertsResponse, } from './types'; -import { resolvePathVariables } from '../../../../management/pages/trusted_apps/service/utils'; +import { resolvePathVariables } from '../../../../management/common/utils'; import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; /** diff --git a/x-pack/plugins/security_solution/public/management/common/utils.test.ts b/x-pack/plugins/security_solution/public/management/common/utils.test.ts index 59455ccd6bb042..8918261b6a4365 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { parseQueryFilterToKQL } from './utils'; +import { parseQueryFilterToKQL, resolvePathVariables } from './utils'; describe('utils', () => { const searchableFields = [`name`, `description`, `entries.value`, `entries.entries.value`]; @@ -39,4 +39,39 @@ describe('utils', () => { ); }); }); + + describe('resolvePathVariables', () => { + it('should resolve defined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( + '/segment1/value1/segment2' + ); + }); + + it('should not resolve undefined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should ignore unused variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should replace multiple variable occurences', () => { + expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( + '/value1/segment1/value1' + ); + }); + + it('should replace multiple variables', () => { + const path = resolvePathVariables('/{var1}/segment1/{var2}', { + var1: 'value1', + var2: 'value2', + }); + + expect(path).toBe('/value1/segment1/value2'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index c8cf761ccaf864..78a95eb4d6f81e 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -19,3 +19,8 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly return kuery; }; + +export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => + Object.keys(variables).reduce((acc, paramName) => { + return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); + }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index d80a7d03903ac6..25f2631ef46ff7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -37,7 +37,6 @@ export interface ServerFailedToReturnEndpointDetails { type: 'serverFailedToReturnEndpointDetails'; payload: ServerApiError; } - export interface ServerReturnedEndpointPolicyResponse { type: 'serverReturnedEndpointPolicyResponse'; payload: GetHostPolicyResponse; @@ -137,19 +136,24 @@ export interface ServerFailedToReturnEndpointsTotal { payload: ServerApiError; } -type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { +export type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { payload: HostIsolationRequestBody; }; -type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { +export type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { payload: EndpointState['isolationRequestState']; }; +export type EndpointDetailsActivityLogChanged = Action<'endpointDetailsActivityLogChanged'> & { + payload: EndpointState['endpointDetails']['activityLog']; +}; + export type EndpointAction = | ServerReturnedEndpointList | ServerFailedToReturnEndpointList | ServerReturnedEndpointDetails | ServerFailedToReturnEndpointDetails + | EndpointDetailsActivityLogChanged | ServerReturnedEndpointPolicyResponse | ServerFailedToReturnEndpointPolicyResponse | ServerReturnedPoliciesForOnboarding diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts new file mode 100644 index 00000000000000..d5416d9f8ec965 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/builders.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Immutable } from '../../../../../common/endpoint/types'; +import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; +import { createUninitialisedResourceState } from '../../../state'; +import { EndpointState } from '../types'; + +export const initialEndpointPageState = (): Immutable => { + return { + hosts: [], + pageSize: 10, + pageIndex: 0, + total: 0, + loading: false, + error: undefined, + endpointDetails: { + activityLog: createUninitialisedResourceState(), + hostDetails: { + details: undefined, + detailsLoading: false, + detailsError: undefined, + }, + }, + policyResponse: undefined, + policyResponseLoading: false, + policyResponseError: undefined, + location: undefined, + policyItems: [], + selectedPolicyId: undefined, + policyItemsLoading: false, + endpointPackageInfo: undefined, + nonExistingPolicies: {}, + agentPolicies: {}, + endpointsExist: true, + patterns: [], + patternsError: undefined, + isAutoRefreshEnabled: true, + autoRefreshInterval: DEFAULT_POLL_INTERVAL, + agentsWithEndpointsTotal: 0, + agentsWithEndpointsTotalError: undefined, + endpointsTotal: 0, + endpointsTotalError: undefined, + queryStrategyVersion: undefined, + policyVersionInfo: undefined, + hostStatus: undefined, + isolationRequestState: createUninitialisedResourceState(), + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index 79f0c5af9bbe3c..5be67a3581c9ec 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -41,9 +41,16 @@ describe('EndpointList store concerns', () => { total: 0, loading: false, error: undefined, - details: undefined, - detailsLoading: false, - detailsError: undefined, + endpointDetails: { + activityLog: { + type: 'UninitialisedResourceState', + }, + hostDetails: { + details: undefined, + detailsLoading: false, + detailsError: undefined, + }, + }, policyResponse: undefined, policyResponseLoading: false, policyResponseError: undefined, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index c52d9220018876..04a04bc38996b8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -18,6 +18,7 @@ import { Immutable, HostResultList, HostIsolationResponse, + EndpointAction, } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { mockEndpointResultList } from './mock_endpoint_result_list'; @@ -25,8 +26,9 @@ import { listData } from './selectors'; import { EndpointState } from '../types'; import { endpointListReducer } from './reducer'; import { endpointMiddlewareFactory } from './middleware'; -import { getEndpointListPath } from '../../../common/routing'; +import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/routing'; import { + createLoadedResourceState, FailedResourceState, isFailedResourceState, isLoadedResourceState, @@ -39,6 +41,7 @@ import { hostIsolationRequestBodyMock, hostIsolationResponseMock, } from '../../../../common/lib/host_isolation/mocks'; +import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator'; jest.mock('../../policy/store/services/ingest', () => ({ sendGetAgentConfigList: () => Promise.resolve({ items: [] }), @@ -192,4 +195,65 @@ describe('endpoint list middleware', () => { expect(failedAction.error).toBe(apiError); }); }); + + describe('handle ActivityLog State Change actions', () => { + const endpointList = getEndpointListApiResponse(); + const search = getEndpointDetailsPath({ + name: 'endpointDetails', + selected_endpoint: endpointList.hosts[0].metadata.agent.id, + }); + const dispatchUserChangedUrl = () => { + dispatch({ + type: 'userChangedUrl', + payload: { + ...history.location, + pathname: '/endpoints', + search: `?${search.split('?').pop()}`, + }, + }); + }; + const fleetActionGenerator = new FleetActionGenerator(Math.random().toString()); + const activityLog = [ + fleetActionGenerator.generate({ + agents: [endpointList.hosts[0].metadata.agent.id], + }), + ]; + const dispatchGetActivityLog = () => { + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createLoadedResourceState(activityLog), + }); + }; + + it('should set ActivityLog state to loading', async () => { + dispatchUserChangedUrl(); + + const loadingDispatched = waitForAction('endpointDetailsActivityLogChanged', { + validate(action) { + return isLoadingResourceState(action.payload); + }, + }); + + const loadingDispatchedResponse = await loadingDispatched; + expect(loadingDispatchedResponse.payload.type).toEqual('LoadingResourceState'); + }); + + it('should set ActivityLog state to loaded when fetching activity log is successful', async () => { + dispatchUserChangedUrl(); + + const loadedDispatched = waitForAction('endpointDetailsActivityLogChanged', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + dispatchGetActivityLog(); + const loadedDispatchedResponse = await loadedDispatched; + const activityLogData = (loadedDispatchedResponse.payload as LoadedResourceState< + EndpointAction[] + >).data; + + expect(activityLogData).toEqual(activityLog); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 9db9932dd4387b..90427d5003384f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -7,6 +7,7 @@ import { HttpStart } from 'kibana/public'; import { + EndpointAction, HostInfo, HostIsolationRequestBody, HostIsolationResponse, @@ -18,6 +19,7 @@ import { ImmutableMiddlewareAPI, ImmutableMiddlewareFactory } from '../../../../ import { isOnEndpointPage, hasSelectedEndpoint, + selectedAgent, uiQueryParams, listData, endpointPackageInfo, @@ -27,6 +29,7 @@ import { isTransformEnabled, getIsIsolationRequestPending, getCurrentIsolationRequestState, + getActivityLogData, } from './selectors'; import { EndpointState, PolicyIds } from '../types'; import { @@ -37,12 +40,13 @@ import { } from '../../policy/store/services/ingest'; import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; import { + ENDPOINT_ACTION_LOG_ROUTE, HOST_METADATA_GET_API, HOST_METADATA_LIST_API, metadataCurrentIndexPattern, } from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; -import { resolvePathVariables } from '../../trusted_apps/service/utils'; +import { resolvePathVariables } from '../../../common/utils'; import { createFailedResourceState, createLoadedResourceState, @@ -336,6 +340,29 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory(getActivityLogData(getState())), + }); + + try { + const activityLog = await coreStart.http.get( + resolvePathVariables(ENDPOINT_ACTION_LOG_ROUTE, { agent_id: selectedAgent(getState()) }) + ); + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createLoadedResourceState(activityLog), + }); + } catch (error) { + dispatch({ + type: 'endpointDetailsActivityLogChanged', + payload: createFailedResourceState(error.body ?? error), + }); + } + // call the policy response api try { const policyResponse = await coreStart.http.get(`/api/endpoint/policy_response`, { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index b2b46e6de98423..19235b792b2702 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { EndpointDetailsActivityLogChanged } from './action'; import { isOnEndpointPage, hasSelectedEndpoint, @@ -12,52 +13,33 @@ import { getCurrentIsolationRequestState, } from './selectors'; import { EndpointState } from '../types'; +import { initialEndpointPageState } from './builders'; import { AppAction } from '../../../../common/store/actions'; import { ImmutableReducer } from '../../../../common/store'; import { Immutable } from '../../../../../common/endpoint/types'; -import { DEFAULT_POLL_INTERVAL } from '../../../common/constants'; import { createUninitialisedResourceState, isUninitialisedResourceState } from '../../../state'; -export const initialEndpointListState: Immutable = { - hosts: [], - pageSize: 10, - pageIndex: 0, - total: 0, - loading: false, - error: undefined, - details: undefined, - detailsLoading: false, - detailsError: undefined, - policyResponse: undefined, - policyResponseLoading: false, - policyResponseError: undefined, - location: undefined, - policyItems: [], - selectedPolicyId: undefined, - policyItemsLoading: false, - endpointPackageInfo: undefined, - nonExistingPolicies: {}, - agentPolicies: {}, - endpointsExist: true, - patterns: [], - patternsError: undefined, - isAutoRefreshEnabled: true, - autoRefreshInterval: DEFAULT_POLL_INTERVAL, - agentsWithEndpointsTotal: 0, - agentsWithEndpointsTotalError: undefined, - endpointsTotal: 0, - endpointsTotalError: undefined, - queryStrategyVersion: undefined, - policyVersionInfo: undefined, - hostStatus: undefined, - isolationRequestState: createUninitialisedResourceState(), -}; +type StateReducer = ImmutableReducer; +type CaseReducer = ( + state: Immutable, + action: Immutable +) => Immutable; -/* eslint-disable-next-line complexity */ -export const endpointListReducer: ImmutableReducer = ( - state = initialEndpointListState, +const handleEndpointDetailsActivityLogChanged: CaseReducer = ( + state, action ) => { + return { + ...state!, + endpointDetails: { + ...state.endpointDetails!, + activityLog: action.payload, + }, + }; +}; + +/* eslint-disable-next-line complexity */ +export const endpointListReducer: StateReducer = (state = initialEndpointPageState(), action) => { if (action.type === 'serverReturnedEndpointList') { const { hosts, @@ -115,18 +97,32 @@ export const endpointListReducer: ImmutableReducer = ( } else if (action.type === 'serverReturnedEndpointDetails') { return { ...state, - details: action.payload.metadata, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + details: action.payload.metadata, + detailsLoading: false, + detailsError: undefined, + }, + }, policyVersionInfo: action.payload.policy_info, hostStatus: action.payload.host_status, - detailsLoading: false, - detailsError: undefined, }; } else if (action.type === 'serverFailedToReturnEndpointDetails') { return { ...state, - detailsError: action.payload, - detailsLoading: false, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: action.payload, + detailsLoading: false, + }, + }, }; + } else if (action.type === 'endpointDetailsActivityLogChanged') { + return handleEndpointDetailsActivityLogChanged(state, action); } else if (action.type === 'serverReturnedPoliciesForOnboarding') { return { ...state, @@ -221,7 +217,6 @@ export const endpointListReducer: ImmutableReducer = ( const stateUpdates: Partial = { location: action.payload, error: undefined, - detailsError: undefined, policyResponseError: undefined, }; @@ -239,6 +234,13 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: undefined, + }, + }, loading: true, policyItemsLoading: true, }; @@ -249,6 +251,14 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsLoading: true, + detailsError: undefined, + }, + }, detailsLoading: true, policyResponseLoading: true, }; @@ -257,8 +267,15 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsLoading: true, + detailsError: undefined, + }, + }, loading: true, - detailsLoading: true, policyResponseLoading: true, policyItemsLoading: true, }; @@ -268,6 +285,13 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, ...stateUpdates, + endpointDetails: { + ...state.endpointDetails, + hostDetails: { + ...state.endpointDetails.hostDetails, + detailsError: undefined, + }, + }, endpointsExist: true, }; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index af95d89fdc10bf..8b6599611ffc40 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -45,11 +45,16 @@ export const listLoading = (state: Immutable): boolean => state.l export const listError = (state: Immutable) => state.error; -export const detailsData = (state: Immutable) => state.details; +export const detailsData = (state: Immutable) => + state.endpointDetails.hostDetails.details; -export const detailsLoading = (state: Immutable): boolean => state.detailsLoading; +export const detailsLoading = (state: Immutable): boolean => + state.endpointDetails.hostDetails.detailsLoading; -export const detailsError = (state: Immutable) => state.detailsError; +export const detailsError = ( + state: Immutable +): EndpointState['endpointDetails']['hostDetails']['detailsError'] => + state.endpointDetails.hostDetails.detailsError; export const policyItems = (state: Immutable) => state.policyItems; @@ -209,7 +214,12 @@ export const uiQueryParams: ( if (value !== undefined) { if (key === 'show') { - if (value === 'policy_response' || value === 'details' || value === 'isolate') { + if ( + value === 'policy_response' || + value === 'details' || + value === 'activity_log' || + value === 'isolate' + ) { data[key] = value; } } else { @@ -240,6 +250,19 @@ export const showView: ( return searchParams.show ?? 'details'; }); +/** + * Returns the selected endpoint's elastic agent Id + * used for fetching endpoint actions log + */ +export const selectedAgent = (state: Immutable): string => { + const hostList = state.hosts; + const { selected_endpoint: selectedEndpoint } = uiQueryParams(state); + return ( + hostList.find((host) => host.metadata.agent.id === selectedEndpoint)?.metadata.elastic.agent + .id || '' + ); +}; + /** * Returns the Host Status which is connected the fleet agent */ @@ -331,3 +354,27 @@ export const getIsolationRequestError: ( return isolateHost.error; } }); + +export const getActivityLogData = ( + state: Immutable +): Immutable => state.endpointDetails.activityLog; + +export const getActivityLogRequestLoading: ( + state: Immutable +) => boolean = createSelector(getActivityLogData, (activityLog) => + isLoadingResourceState(activityLog) +); + +export const getActivityLogRequestLoaded: ( + state: Immutable +) => boolean = createSelector(getActivityLogData, (activityLog) => + isLoadedResourceState(activityLog) +); + +export const getActivityLogError: ( + state: Immutable +) => ServerApiError | undefined = createSelector(getActivityLogData, (activityLog) => { + if (isFailedResourceState(activityLog)) { + return activityLog.error; + } +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index 74eee0602722b0..ac06f98004f597 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -14,6 +14,7 @@ import { PolicyData, MetadataQueryStrategyVersions, HostStatus, + EndpointAction, HostIsolationResponse, } from '../../../../common/endpoint/types'; import { ServerApiError } from '../../../common/types'; @@ -34,12 +35,17 @@ export interface EndpointState { loading: boolean; /** api error from retrieving host list */ error?: ServerApiError; - /** details data for a specific host */ - details?: Immutable; - /** details page is retrieving data */ - detailsLoading: boolean; - /** api error from retrieving host details */ - detailsError?: ServerApiError; + endpointDetails: { + activityLog: AsyncResourceState; + hostDetails: { + /** details data for a specific host */ + details?: Immutable; + /** details page is retrieving data */ + detailsLoading: boolean; + /** api error from retrieving host details */ + detailsError?: ServerApiError; + }; + }; /** Holds the Policy Response for the Host currently being displayed in the details */ policyResponse?: HostPolicyResponse; /** policyResponse is being retrieved */ @@ -108,7 +114,7 @@ export interface EndpointIndexUIQueryParams { /** Which page to show */ page_index?: string; /** show the policy response or host details */ - show?: 'policy_response' | 'details' | 'isolate'; + show?: 'policy_response' | 'activity_log' | 'details' | 'isolate'; /** Query text from search bar*/ admin_query?: string; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx new file mode 100644 index 00000000000000..3e228be4565b1c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_details_tabs.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; +import { EndpointIndexUIQueryParams } from '../../../types'; +export enum EndpointDetailsTabsTypes { + overview = 'overview', + activityLog = 'activity_log', +} + +export type EndpointDetailsTabsId = + | EndpointDetailsTabsTypes.overview + | EndpointDetailsTabsTypes.activityLog; + +interface EndpointDetailsTabs { + id: string; + name: string; + content: JSX.Element; +} + +const StyledEuiTabbedContent = styled(EuiTabbedContent)` + overflow: hidden; + padding-bottom: ${(props) => props.theme.eui.paddingSizes.xl}; + + > [role='tabpanel'] { + height: 100%; + padding-right: 12px; + overflow: hidden; + overflow-y: auto; + ::-webkit-scrollbar { + -webkit-appearance: none; + width: 4px; + } + ::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: rgba(0, 0, 0, 0.5); + -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); + } + } +`; + +export const EndpointDetailsFlyoutTabs = memo( + ({ show, tabs }: { show: EndpointIndexUIQueryParams['show']; tabs: EndpointDetailsTabs[] }) => { + const [selectedTabId, setSelectedTabId] = useState(() => { + return show === 'details' + ? EndpointDetailsTabsTypes.overview + : EndpointDetailsTabsTypes.activityLog; + }); + + const handleTabClick = useCallback( + (tab: EuiTabbedContentTab) => setSelectedTabId(tab.id as EndpointDetailsTabsId), + [setSelectedTabId] + ); + + const selectedTab = useMemo(() => tabs.find((tab) => tab.id === selectedTabId), [ + tabs, + selectedTabId, + ]); + + return ( + + ); + } +); + +EndpointDetailsFlyoutTabs.displayName = 'EndpointDetailsFlyoutTabs'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx new file mode 100644 index 00000000000000..de6d2ecf36eccf --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx @@ -0,0 +1,57 @@ +/* + * 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, { memo } from 'react'; + +import { EuiAvatar, EuiComment, EuiText } from '@elastic/eui'; +import { Immutable, EndpointAction } from '../../../../../../../common/endpoint/types'; +import { FormattedDateAndTime } from '../../../../../../common/components/endpoint/formatted_date_time'; +import { useEuiTheme } from '../../../../../../common/lib/theme/use_eui_theme'; + +export const LogEntry = memo( + ({ endpointAction }: { endpointAction: Immutable }) => { + const euiTheme = useEuiTheme(); + const isIsolated = endpointAction?.data.command === 'isolate'; + + // do this better when we can distinguish between endpoint events vs user events + const iconType = endpointAction.user_id === 'sys' ? 'dot' : isIsolated ? 'lock' : 'lockOpen'; + const commentType = endpointAction.user_id === 'sys' ? 'update' : 'regular'; + const timelineIcon = ( + + ); + const event = `${isIsolated ? 'isolated' : 'unisolated'} host`; + const hasComment = !!endpointAction.data.comment; + + return ( + + {hasComment ? ( + +

{endpointAction.data.comment}

+
+ ) : undefined} +
+ ); + } +); + +LogEntry.displayName = 'LogEntry'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx new file mode 100644 index 00000000000000..50c91730e332c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_activity_log.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; + +import { EuiEmptyPrompt, EuiSpacer } from '@elastic/eui'; +import { LogEntry } from './components/log_entry'; +import * as i18 from '../translations'; +import { SearchBar } from '../../../../components/search_bar'; +import { Immutable, EndpointAction } from '../../../../../../common/endpoint/types'; +import { AsyncResourceState } from '../../../../state'; + +export const EndpointActivityLog = memo( + ({ endpointActions }: { endpointActions: AsyncResourceState> }) => { + // TODO + const onSearch = useCallback(() => {}, []); + return ( + <> + + {endpointActions.type !== 'LoadedResourceState' || !endpointActions.data.length ? ( + {'No logged actions'}

} + body={

{'No actions have been logged for this endpoint.'}

} + /> + ) : ( + <> + + + {endpointActions.data.map((endpointAction) => ( + + ))} + + )} + + ); + } +); + +EndpointActivityLog.displayName = 'EndpointActivityLog'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index c9db78f425afae..16cae79d42c0f2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -258,6 +258,7 @@ export const EndpointDetails = memo( return ( <> + > => ({ + type: 'LoadedResourceState', + data: [ + { + action_id: '1', + '@timestamp': moment().subtract(1, 'hours').fromNow().toString(), + expiration: moment().add(3, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'sys', + data: { + command: 'isolate', + }, + }, + { + action_id: '2', + '@timestamp': moment().subtract(2, 'hours').fromNow().toString(), + expiration: moment().add(1, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'ash', + data: { + command: 'isolate', + comment: 'Sem et tortor consequat id porta nibh venenatis cras sed.', + }, + }, + { + action_id: '3', + '@timestamp': moment().subtract(4, 'hours').fromNow().toString(), + expiration: moment().add(1, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'someone', + data: { + command: 'unisolate', + comment: 'Turpis egestas pretium aenean pharetra.', + }, + }, + { + action_id: '4', + '@timestamp': moment().subtract(1, 'day').fromNow().toString(), + expiration: moment().add(3, 'day').fromNow().toString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: [`${selectedEndpoint}`], + user_id: 'ash', + data: { + command: 'isolate', + comment: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, \ + sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + }, + ], +}); + +export default { + title: 'Endpoints/Endpoint Details', + component: EndpointDetailsFlyout, + decorators: [ + (Story: ComponentType) => ( + + + + ), + ], +}; + +export const Tabs = () => ( + {'Endpoint Details'}, + }, + { + id: 'activity_log', + name: 'Activity Log', + content: ActivityLog(), + }, + ]} + /> +); + +export const ActivityLog = () => ( + +); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 09b1bbceef21d1..8d985f3a4cfe27 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import React, { useCallback, useEffect, memo } from 'react'; +import React, { useCallback, useEffect, useMemo, memo } from 'react'; +import styled from 'styled-components'; import { EuiFlyout, EuiFlyoutBody, @@ -16,6 +17,8 @@ import { EuiSpacer, EuiEmptyPrompt, EuiToolTip, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,8 +29,11 @@ import { uiQueryParams, detailsData, detailsError, - showView, detailsLoading, + getActivityLogData, + getActivityLogError, + getActivityLogRequestLoading, + showView, policyResponseConfigurations, policyResponseActions, policyResponseFailedOrWarningActionCount, @@ -39,14 +45,36 @@ import { policyResponseAppliedRevision, } from '../../store/selectors'; import { EndpointDetails } from './endpoint_details'; +import { EndpointActivityLog } from './endpoint_activity_log'; import { PolicyResponse } from './policy_response'; +import * as i18 from '../translations'; import { HostMetadata } from '../../../../../../common/endpoint/types'; +import { + EndpointDetailsFlyoutTabs, + EndpointDetailsTabsTypes, +} from './components/endpoint_details_tabs'; + import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date'; import { EndpointIsolateFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader'; import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding'; import { getEndpointListPath } from '../../../../common/routing'; +const DetailsFlyoutBody = styled(EuiFlyoutBody)` + overflow-y: hidden; + flex: 1; + + .euiFlyoutBody__overflow { + overflow: hidden; + mask-image: none; + } + + .euiFlyoutBody__overflowContent { + height: 100%; + display: flex; + } +`; + export const EndpointDetailsFlyout = memo(() => { const history = useHistory(); const toasts = useToasts(); @@ -55,13 +83,51 @@ export const EndpointDetailsFlyout = memo(() => { selected_endpoint: selectedEndpoint, ...queryParamsWithoutSelectedEndpoint } = queryParams; - const details = useEndpointSelector(detailsData); + + const activityLog = useEndpointSelector(getActivityLogData); + const activityLoading = useEndpointSelector(getActivityLogRequestLoading); + const activityError = useEndpointSelector(getActivityLogError); + const hostDetails = useEndpointSelector(detailsData); + const hostDetailsLoading = useEndpointSelector(detailsLoading); + const hostDetailsError = useEndpointSelector(detailsError); + const policyInfo = useEndpointSelector(policyVersionInfo); const hostStatus = useEndpointSelector(hostStatusInfo); - const loading = useEndpointSelector(detailsLoading); - const error = useEndpointSelector(detailsError); const show = useEndpointSelector(showView); + const ContentLoadingMarkup = useMemo( + () => ( + <> + + + + + ), + [] + ); + + const tabs = [ + { + id: EndpointDetailsTabsTypes.overview, + name: i18.OVERVIEW, + content: + hostDetails === undefined ? ( + ContentLoadingMarkup + ) : ( + + ), + }, + { + id: EndpointDetailsTabsTypes.activityLog, + name: i18.ACTIVITY_LOG, + content: activityLoading ? ( + ContentLoadingMarkup + ) : ( + + ), + }, + ]; + const handleFlyoutClose = useCallback(() => { const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint; history.push( @@ -73,7 +139,7 @@ export const EndpointDetailsFlyout = memo(() => { }, [history, queryParamsWithoutSelectedEndpoint]); useEffect(() => { - if (error !== undefined) { + if (hostDetailsError !== undefined) { toasts.addDanger({ title: i18n.translate('xpack.securitySolution.endpoint.details.errorTitle', { defaultMessage: 'Could not find host', @@ -83,7 +149,17 @@ export const EndpointDetailsFlyout = memo(() => { }), }); } - }, [error, toasts]); + if (activityError !== undefined) { + toasts.addDanger({ + title: i18n.translate('xpack.securitySolution.endpoint.activityLog.errorTitle', { + defaultMessage: 'Could not find activity log for host', + }), + text: i18n.translate('xpack.securitySolution.endpoint.activityLog.errorBody', { + defaultMessage: 'Please exit the flyout and select another host with actions.', + }), + }); + } + }, [hostDetailsError, activityError, toasts]); return ( { style={{ zIndex: 4001 }} data-test-subj="endpointDetailsFlyout" size="m" + paddingSize="m" > - {loading ? ( + {hostDetailsLoading || activityLoading ? ( ) : ( - +

- {details?.host?.hostname} + {hostDetails?.host?.hostname}

)}
- {details === undefined ? ( + {hostDetails === undefined ? ( ) : ( <> - {show === 'details' && ( - - - + {(show === 'details' || show === 'activity_log') && ( + + + + + + + )} - {show === 'policy_response' && } + {show === 'policy_response' && } - {show === 'isolate' && } + {show === 'isolate' && } )}
diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts new file mode 100644 index 00000000000000..fd2806713183bf --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const OVERVIEW = i18n.translate('xpack.securitySolution.endpointDetails.overview', { + defaultMessage: 'Overview', +}); + +export const ACTIVITY_LOG = i18n.translate('xpack.securitySolution.endpointDetails.activityLog', { + defaultMessage: 'Activity Log', +}); + +export const SEARCH_ACTIVITY_LOG = i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.search', + { + defaultMessage: 'Search activity log', + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 5f572251daeda5..01bccc81b5063b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -30,7 +30,7 @@ import { GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; -import { resolvePathVariables } from './utils'; +import { resolvePathVariables } from '../../../common/utils'; import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts deleted file mode 100644 index c2067f9d0848f6..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts +++ /dev/null @@ -1,45 +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 { resolvePathVariables } from './utils'; - -describe('utils', () => { - describe('resolvePathVariables', () => { - it('should resolve defined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( - '/segment1/value1/segment2' - ); - }); - - it('should not resolve undefined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should ignore unused variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should replace multiple variable occurences', () => { - expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( - '/value1/segment1/value1' - ); - }); - - it('should replace multiple variables', () => { - const path = resolvePathVariables('/{var1}/segment1/{var2}', { - var1: 'value1', - var2: 'value2', - }); - - expect(path).toBe('/value1/segment1/value2'); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts deleted file mode 100644 index 89067e575665dd..00000000000000 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts +++ /dev/null @@ -1,11 +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. - */ - -export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => - Object.keys(variables).reduce((acc, paramName) => { - return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); - }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 3f02d505daea1e..dc0032243312f0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -31,7 +31,7 @@ import { import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; -import { resolvePathVariables } from '../service/utils'; +import { resolvePathVariables } from '../../../common/utils'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; diff --git a/x-pack/plugins/security_solution/public/management/store/reducer.ts b/x-pack/plugins/security_solution/public/management/store/reducer.ts index bf8cd416a3e395..25c7c87c6f5c95 100644 --- a/x-pack/plugins/security_solution/public/management/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/store/reducer.ts @@ -19,14 +19,12 @@ import { import { ImmutableCombineReducers } from '../../common/store'; import { Immutable } from '../../../common/endpoint/types'; import { ManagementState } from '../types'; -import { - endpointListReducer, - initialEndpointListState, -} from '../pages/endpoint_hosts/store/reducer'; +import { endpointListReducer } from '../pages/endpoint_hosts/store/reducer'; import { initialTrustedAppsPageState } from '../pages/trusted_apps/store/builders'; import { trustedAppsPageReducer } from '../pages/trusted_apps/store/reducer'; import { initialEventFiltersPageState } from '../pages/event_filters/store/builders'; import { eventFiltersPageReducer } from '../pages/event_filters/store/reducer'; +import { initialEndpointPageState } from '../pages/endpoint_hosts/store/builders'; const immutableCombineReducers: ImmutableCombineReducers = combineReducers; @@ -35,7 +33,7 @@ const immutableCombineReducers: ImmutableCombineReducers = combineReducers; */ export const mockManagementState: Immutable = { [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(), - [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointListState, + [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointPageState(), [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: initialTrustedAppsPageState(), [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: initialEventFiltersPageState(), }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts new file mode 100644 index 00000000000000..487ee16558fec3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ENDPOINT_ACTION_LOG_ROUTE } from '../../../../common/endpoint/constants'; +import { EndpointActionLogRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { actionsLogRequestHandler } from './audit_log_handler'; + +import { SecuritySolutionPluginRouter } from '../../../types'; +import { EndpointAppContext } from '../../types'; + +/** + * Registers the endpoint activity_log route + */ +export function registerActionAuditLogRoutes( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) { + router.get( + { + path: ENDPOINT_ACTION_LOG_ROUTE, + validate: EndpointActionLogRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + actionsLogRequestHandler(endpointContext) + ); +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts new file mode 100644 index 00000000000000..fdbb9608463e9c --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log_handler.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { RequestHandler } from 'kibana/server'; +import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; +import { EndpointActionLogRequestSchema } from '../../../../common/endpoint/schema/actions'; + +import { SecuritySolutionRequestHandlerContext } from '../../../types'; +import { EndpointAppContext } from '../../types'; + +export const actionsLogRequestHandler = ( + endpointContext: EndpointAppContext +): RequestHandler< + TypeOf, + unknown, + unknown, + SecuritySolutionRequestHandlerContext +> => { + const logger = endpointContext.logFactory.get('audit_log'); + return async (context, req, res) => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + let result; + try { + result = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + body: { + query: { + match: { + agents: req.params.agent_id, + }, + }, + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + }, + }); + } catch (error) { + logger.error(error); + throw error; + } + if (result?.statusCode !== 200) { + logger.error(`Error fetching actions log for agent_id ${req.params.agent_id}`); + throw new Error(`Error fetching actions log for agent_id ${req.params.agent_id}`); + } + + return res.ok({ + body: result.body.hits.hits.map((e) => e._source), + }); + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts index 9dec4fb2cbb797..e95a33253034d4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -6,3 +6,4 @@ */ export * from './isolation'; +export * from './audit_log'; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 2507475592e888..732ae482234213 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -75,7 +75,10 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; -import { registerHostIsolationRoutes } from './endpoint/routes/actions'; +import { + registerHostIsolationRoutes, + registerActionAuditLogRoutes, +} from './endpoint/routes/actions'; import { EndpointArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; @@ -291,6 +294,7 @@ export class Plugin implements IPlugin Date: Thu, 3 Jun 2021 10:03:08 +0200 Subject: [PATCH 29/77] Link and formatting fix (#101243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: ✏️ make "Risk Matrix" link absolute The page can be rendered in different environments, so we should have this link absolute so that it always works, for example, it would not work in PRs themselves. * docs: ✏️ remove formatting breaks In PRs Markdown formatting breaks are rendered incorrectly, so we remove them. --- .github/PULL_REQUEST_TEMPLATE.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 336f7e5165d07f..726e4257a5aac0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,18 +21,16 @@ Delete any items that are not applicable to this PR. 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. +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: +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) | +| [See more potential risk examples](https://github.com/elastic/kibana/blob/master/RISK_MATRIX.mdx) | ### For maintainers From 6f106e468747f03ba8f5b94e615dc65342251b49 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Thu, 3 Jun 2021 11:49:00 +0300 Subject: [PATCH 30/77] Gauge/goal: Tooltip always includes "_all" (#101064) * Don't show _all for goal and gauge in tooltip * add unit test --- .../tooltip/_pointseries_tooltip_formatter.js | 4 +++- .../_pointseries_tooltip_formatter.test.js | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js index cb8a8f72c51727..5e1f0bfbb44644 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js +++ b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.js @@ -31,6 +31,7 @@ export function pointSeriesTooltipFormatter() { const details = []; const isGauge = config.get('gauge', false); + const chartType = config.get('type', undefined); const isPercentageMode = config.get(isGauge ? 'gauge.percentageMode' : 'percentageMode', false); const isSetColorRange = config.get('setColorRange', false); @@ -44,7 +45,8 @@ export function pointSeriesTooltipFormatter() { }); } - if (datum.x !== null && datum.x !== undefined) { + // For goal and gauge we have only one value for x - '_all'. It doesn't have sense to show it + if (datum.x !== null && datum.x !== undefined && !['goal', 'gauge'].includes(chartType)) { addDetail(data.xAxisLabel, data.xAxisFormatter(datum.x)); } diff --git a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js index 5c0548ea399b75..a207b1f4360b6a 100644 --- a/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js +++ b/src/plugins/vis_type_vislib/public/vislib/components/tooltip/_pointseries_tooltip_formatter.test.js @@ -96,4 +96,27 @@ describe('tooltipFormatter', function () { const $rows = $el.find('tr'); expect($rows.length).toBe(3); }); + + it('renders correctly for gauge/goal visualizations', function () { + const event = _.cloneDeep(baseEvent); + let type = 'gauge'; + event.config.get = (name) => { + const config = { + setColorRange: false, + gauge: false, + percentageMode: false, + type, + }; + return config[name]; + }; + + let $el = $(tooltipFormatter(event, uiSettings)); + let $rows = $el.find('tr'); + expect($rows.length).toBe(2); + + type = 'goal'; + $el = $(tooltipFormatter(event, uiSettings)); + $rows = $el.find('tr'); + expect($rows.length).toBe(2); + }); }); From 8097f3646586dbd49028b75f0ffa31ffe557726a Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Thu, 3 Jun 2021 12:28:09 +0300 Subject: [PATCH 31/77] attempt at tree shaking (#101147) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-ui-shared-deps/entry.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js index d3755ed7c5f293..b8d21a473c65f5 100644 --- a/packages/kbn-ui-shared-deps/entry.js +++ b/packages/kbn-ui-shared-deps/entry.js @@ -44,7 +44,8 @@ export const Theme = require('./theme.ts'); export const Lodash = require('lodash'); export const LodashFp = require('lodash/fp'); -export const Fflate = require('fflate/esm/browser'); +import { unzlibSync, strFromU8 } from 'fflate'; +export const Fflate = { unzlibSync, strFromU8 }; // runtime deps which don't need to be copied across all bundles export const TsLib = require('tslib'); From 83b47c1bc81e9dd5f49b2f093a902f49455a6134 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 3 Jun 2021 12:37:45 +0300 Subject: [PATCH 32/77] [TSVB] Math params._interval is incorrect when using entire timerange mode (#100775) * [TSVB] Math params._interval is incorrect when using entire timerange mode Closes: #100615 * fix jest * rename get -> overwrite * apply fix for "bucket script" * Update date_histogram.js Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../lib/vis_data/helpers/bucket_transform.js | 19 ++++++--- .../series/date_histogram.js | 35 +++++++++------- .../series/date_histogram.test.js | 42 +++++++++++++++---- .../series/metric_buckets.js | 21 ++-------- .../series/sibling_buckets.js | 23 +++------- .../table/date_histogram.js | 25 ++++++----- .../table/metric_buckets.js | 12 ++---- .../table/sibling_buckets.js | 14 +++---- .../response_processors/series/math.js | 6 ++- .../response_processors/series/math.test.js | 21 +++++++++- .../series/build_request_body.test.ts | 1 - 11 files changed, 124 insertions(+), 95 deletions(-) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js index 2877373ffba9a1..16e7b9d6072cba 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/bucket_transform.js @@ -7,11 +7,11 @@ */ import { getBucketsPath } from './get_buckets_path'; -import { parseInterval } from './parse_interval'; import { set } from '@elastic/safer-lodash-set'; import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; import { MODEL_SCRIPTS } from './moving_fn_scripts'; +import { convertIntervalToUnit } from './unit_to_seconds'; function checkMetric(metric, fields) { fields.forEach((field) => { @@ -161,19 +161,24 @@ export const bucketTransform = { }; }, - derivative: (bucket, metrics, bucketSize) => { + derivative: (bucket, metrics, intervalString) => { checkMetric(bucket, ['type', 'field']); + const body = { derivative: { buckets_path: getBucketsPath(bucket.field, metrics), gap_policy: 'skip', // seems sane - unit: bucketSize, + unit: intervalString, }, }; + if (bucket.gap_policy) body.derivative.gap_policy = bucket.gap_policy; if (bucket.unit) { - body.derivative.unit = /^([\d]+)([shmdwMy]|ms)$/.test(bucket.unit) ? bucket.unit : bucketSize; + body.derivative.unit = /^([\d]+)([shmdwMy]|ms)$/.test(bucket.unit) + ? bucket.unit + : intervalString; } + return body; }, @@ -214,8 +219,10 @@ export const bucketTransform = { }; }, - calculation: (bucket, metrics, bucketSize) => { + calculation: (bucket, metrics, intervalString) => { checkMetric(bucket, ['variables', 'script']); + const inMsInterval = convertIntervalToUnit(intervalString, 'ms'); + const body = { bucket_script: { buckets_path: bucket.variables.reduce((acc, row) => { @@ -226,7 +233,7 @@ export const bucketTransform = { source: bucket.script, lang: 'painless', params: { - _interval: parseInterval(bucketSize).asMilliseconds(), + _interval: inMsInterval?.value, }, }, gap_policy: 'skip', // seems sane diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index f82f332df19fd1..253612c0274ad9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -29,17 +29,20 @@ export function dateHistogram( const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const { timeField, interval, maxBars } = getIntervalAndTimefield(panel, series, seriesIndex); - const { bucketSize, intervalString } = getBucketSize( - req, - interval, - capabilities, - maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings - ); + const { from, to } = offsetTime(req, series.offset_time); - const getDateHistogramForLastBucketMode = () => { - const { from, to } = offsetTime(req, series.offset_time); + let bucketInterval; + + const overwriteDateHistogramForLastBucketMode = () => { const { timezone } = capabilities; + const { intervalString } = getBucketSize( + req, + interval, + capabilities, + maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings + ); + overwrite(doc, `aggs.${series.id}.aggs.timeseries.date_histogram`, { field: timeField, min_doc_count: 0, @@ -50,25 +53,29 @@ export function dateHistogram( }, ...dateHistogramInterval(intervalString), }); + + bucketInterval = intervalString; }; - const getDateHistogramForEntireTimerangeMode = () => + const overwriteDateHistogramForEntireTimerangeMode = () => { overwrite(doc, `aggs.${series.id}.aggs.timeseries.auto_date_histogram`, { field: timeField, buckets: 1, }); + bucketInterval = `${to.valueOf() - from.valueOf()}ms`; + }; + isLastValueTimerangeMode(panel, series) - ? getDateHistogramForLastBucketMode() - : getDateHistogramForEntireTimerangeMode(); + ? overwriteDateHistogramForLastBucketMode() + : overwriteDateHistogramForEntireTimerangeMode(); overwrite(doc, `aggs.${series.id}.meta`, { timeField, - intervalString, - bucketSize, + panelId: panel.id, seriesId: series.id, + intervalString: bucketInterval, index: panel.use_kibana_indexes ? seriesIndex.indexPattern?.id : undefined, - panelId: panel.id, }); return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index 741eb93267f4c2..2cd7a213b273e9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -86,7 +86,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 10, intervalString: '10s', timeField: '@timestamp', seriesId: 'test', @@ -128,7 +127,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 10, intervalString: '10s', timeField: '@timestamp', seriesId: 'test', @@ -173,7 +171,6 @@ describe('dateHistogram(req, panel, series)', () => { }, }, meta: { - bucketSize: 20, intervalString: '20s', timeField: 'timestamp', seriesId: 'test', @@ -185,8 +182,11 @@ describe('dateHistogram(req, panel, series)', () => { }); describe('dateHistogram for entire time range mode', () => { - test('should ignore entire range mode for timeseries', async () => { + beforeEach(() => { panel.time_range_mode = 'entire_time_range'; + }); + + test('should ignore entire range mode for timeseries', async () => { panel.type = 'timeseries'; const next = (doc) => doc; @@ -204,9 +204,36 @@ describe('dateHistogram(req, panel, series)', () => { expect(doc.aggs.test.aggs.timeseries.date_histogram).toBeDefined(); }); - test('should returns valid date histogram for entire range mode', async () => { - panel.time_range_mode = 'entire_time_range'; + test('should set meta values', async () => { + // set 15 minutes (=== 900000ms) interval; + req.body.timerange = { + min: '2021-01-01T00:00:00Z', + max: '2021-01-01T00:15:00Z', + }; + + const next = (doc) => doc; + const doc = await dateHistogram( + req, + panel, + series, + config, + indexPattern, + capabilities, + uiSettings + )(next)({}); + expect(doc.aggs.test.meta).toMatchInlineSnapshot(` + Object { + "index": undefined, + "intervalString": "900000ms", + "panelId": "panelId", + "seriesId": "test", + "timeField": "@timestamp", + } + `); + }); + + test('should returns valid date histogram for entire range mode', async () => { const next = (doc) => doc; const doc = await dateHistogram( req, @@ -232,8 +259,7 @@ describe('dateHistogram(req, panel, series)', () => { meta: { timeField: '@timestamp', seriesId: 'test', - bucketSize: 10, - intervalString: '10s', + intervalString: '3600000ms', panelId: 'panelId', }, }, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 29a11bf163e0be..33c6622f739416 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -7,30 +7,17 @@ */ import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; -import { UI_SETTINGS } from '../../../../../../data/common'; +import { get } from 'lodash'; -export function metricBuckets( - req, - panel, - series, - esQueryConfig, - seriesIndex, - capabilities, - uiSettings -) { +export function metricBuckets(req, panel, series) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - - const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); - const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - series.metrics .filter((row) => !/_bucket$/.test(row.type) && !/^series/.test(row.type)) .forEach((metric) => { const fn = bucketTransform[metric.type]; + const intervalString = get(doc, `aggs.${series.id}.meta.intervalString`); + if (fn) { try { const bucket = fn(metric, series.metrics, intervalString); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index dbeb3b1393bd52..c3075dd6dcac00 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -6,39 +6,28 @@ * Side Public License, v 1. */ +import { get } from 'lodash'; import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; -import { UI_SETTINGS } from '../../../../../../data/common'; -export function siblingBuckets( - req, - panel, - series, - esQueryConfig, - seriesIndex, - capabilities, - uiSettings -) { +export function siblingBuckets(req, panel, series) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, seriesIndex); - const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - series.metrics .filter((row) => /_bucket$/.test(row.type)) .forEach((metric) => { const fn = bucketTransform[metric.type]; + const intervalString = get(doc, `aggs.${series.id}.meta.intervalString`); + if (fn) { try { - const bucket = fn(metric, series.metrics, bucketSize); + const bucket = fn(metric, series.metrics, intervalString); overwrite(doc, `aggs.${series.id}.aggs.${metric.id}`, bucket); } catch (e) { // meh } } }); + return next(doc); }; } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 3e883abc9e5e08..92ac4078a3835a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -20,6 +20,7 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const { timeField, interval } = getIntervalAndTimefield(panel, {}, seriesIndex); + const { from, to } = getTimerange(req); const meta = { timeField, @@ -27,14 +28,8 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti panelId: panel.id, }; - const getDateHistogramForLastBucketMode = () => { - const { bucketSize, intervalString } = getBucketSize( - req, - interval, - capabilities, - barTargetUiSettings - ); - const { from, to } = getTimerange(req); + const overwriteDateHistogramForLastBucketMode = () => { + const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); const { timezone } = capabilities; panel.series.forEach((column) => { @@ -54,12 +49,13 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), { ...meta, intervalString, - bucketSize, }); }); }; - const getDateHistogramForEntireTimerangeMode = () => { + const overwriteDateHistogramForEntireTimerangeMode = () => { + const intervalString = `${to.valueOf() - from.valueOf()}ms`; + panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); @@ -68,13 +64,16 @@ export function dateHistogram(req, panel, esQueryConfig, seriesIndex, capabiliti buckets: 1, }); - overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), meta); + overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), { + ...meta, + intervalString, + }); }); }; isLastValueTimerangeMode(panel) - ? getDateHistogramForLastBucketMode() - : getDateHistogramForEntireTimerangeMode(); + ? overwriteDateHistogramForLastBucketMode() + : overwriteDateHistogramForEntireTimerangeMode(); return next(doc); }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index 421f9d2d75f0c6..8e0d0060225fff 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -6,19 +6,13 @@ * Side Public License, v 1. */ +import { get } from 'lodash'; import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -import { UI_SETTINGS } from '../../../../../../data/common'; -export function metricBuckets(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { +export function metricBuckets(req, panel) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); - const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics @@ -27,7 +21,9 @@ export function metricBuckets(req, panel, esQueryConfig, seriesIndex, capabiliti const fn = bucketTransform[metric.type]; if (fn) { try { + const intervalString = get(doc, aggRoot.replace(/\.aggs$/, '.meta.intervalString')); const bucket = fn(metric, column.metrics, intervalString); + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, bucket); } catch (e) { // meh diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index 9b4b0f244fc2c1..6ce956c490900f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -7,18 +7,12 @@ */ import { overwrite } from '../../helpers'; -import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; -import { UI_SETTINGS } from '../../../../../../data/common'; +import { get } from 'lodash'; -export function siblingBuckets(req, panel, esQueryConfig, seriesIndex, capabilities, uiSettings) { +export function siblingBuckets(req, panel) { return (next) => async (doc) => { - const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, seriesIndex); - const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); - panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); column.metrics @@ -27,7 +21,9 @@ export function siblingBuckets(req, panel, esQueryConfig, seriesIndex, capabilit const fn = bucketTransform[metric.type]; if (fn) { try { - const bucket = fn(metric, column.metrics, bucketSize); + const intervalString = get(doc, aggRoot.replace(/\.aggs$/, '.meta.intervalString')); + const bucket = fn(metric, column.metrics, intervalString); + overwrite(doc, `${aggRoot}.${metric.id}`, bucket); } catch (e) { // meh diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js index 403b486cc4d091..d3cff76524ee36 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { convertIntervalToUnit } from '../../helpers/unit_to_seconds'; + const percentileValueMatch = /\[([0-9\.]+)\]$/; import { startsWith, flatten, values, first, last } from 'lodash'; import { getDefaultDecoration } from '../../helpers/get_default_decoration'; @@ -82,13 +84,15 @@ export function mathAgg(resp, panel, series, meta, extractFields) { if (someNull) return [ts, null]; try { // calculate the result based on the user's script and return the value + const inMsInterval = convertIntervalToUnit(split.meta?.intervalString || 0, 'ms'); + const result = evaluate(mathMetric.script, { params: { ...params, _index: index, _timestamp: ts, _all: all, - _interval: split.meta.bucketSize * 1000, + _interval: inMsInterval?.value, }, }); // if the result is an object (usually when the user is working with maps and functions) flatten the results and return the last value. diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js index 1e30720d6e5b21..7b5eb1e029069f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.test.js @@ -54,7 +54,7 @@ describe('math(resp, panel, series)', () => { aggregations: { test: { meta: { - bucketSize: 5, + intervalString: '5s', }, buckets: [ { @@ -124,6 +124,25 @@ describe('math(resp, panel, series)', () => { ); }); + test('should works with predefined variables (params._interval)', async () => { + const expectedInterval = 5000; + + series.metrics[2].script = 'params._interval'; + + const next = await mathAgg(resp, panel, series)((results) => results); + const results = await stdMetric(resp, panel, series)(next)([]); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual( + expect.objectContaining({ + data: [ + [1, expectedInterval], + [2, expectedInterval], + ], + }) + ); + }); + test('throws on actual tinymath expression errors #1', async () => { series.metrics[2].script = 'notExistingFn(params.a)'; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts index 5b865d451003a9..46acbb27e15e14 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts @@ -153,7 +153,6 @@ describe('buildRequestBody(req)', () => { time_zone: 'UTC', }, meta: { - bucketSize: 10, intervalString: '10s', seriesId: 'c9b5f9c0-e403-11e6-be91-6f7688e9fac7', timeField: '@timestamp', From 4e5652d05a0f2b3c0589276bb8ad79ebc7f5ccc4 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Thu, 3 Jun 2021 12:54:43 +0200 Subject: [PATCH 33/77] [Lens] setFocusTrap after animation is ended and not with timeout (#101148) --- .../config_panel/dimension_container.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index a8d610f2740de5..b14d391c2c9696 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -44,15 +44,6 @@ export function DimensionContainer({ setFocusTrapIsEnabled(false); }, [handleClose]); - useEffect(() => { - if (isOpen) { - // without setTimeout here the flyout pushes content when animating - setTimeout(() => { - setFocusTrapIsEnabled(true); - }, 255); - } - }, [isOpen]); - const closeOnEscape = useCallback( (event: KeyboardEvent) => { if (event.key === keys.ESCAPE) { @@ -83,6 +74,13 @@ export function DimensionContainer({ role="dialog" aria-labelledby="lnsDimensionContainerTitle" className="lnsDimensionContainer euiFlyout" + onAnimationEnd={() => { + if (isOpen) { + // EuiFocusTrap interferes with animating elements with absolute position: + // running this onAnimationEnd, otherwise the flyout pushes content when animating + setFocusTrapIsEnabled(true); + } + }} > Date: Thu, 3 Jun 2021 14:06:57 +0200 Subject: [PATCH 34/77] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20connect=20dasdhboa?= =?UTF-8?q?rd=20telemetry=20to=20persistable=20state=20(#99498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 connect dasdhboard telemetry to persistable state * fix: 🐛 do not mutate .telemetry() stats objects * feat: 🎸 populate stats object with embeddable telemetry * feat: 🎸 embeddable telemetry schema * feat: 🎸 update telemetry schema * feat: 🎸 add descriptions to dashboard collector * chore: 🤖 update telemetry schema Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/usage/dashboard_telemetry.ts | 22 +++++++++++++++++++ .../server/usage/register_collector.ts | 17 ++++++++++++++ src/plugins/embeddable/public/plugin.tsx | 2 +- src/plugins/embeddable/server/plugin.ts | 4 ++-- src/plugins/telemetry/schema/oss_plugins.json | 20 +++++++++++++++-- .../public/dynamic_actions/action_factory.ts | 2 +- .../ui_actions_enhanced/server/plugin.ts | 2 +- .../telemetry/dynamic_actions_collector.ts | 3 ++- 8 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts index 02d492de4fe666..912dc04d16d092 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts @@ -41,6 +41,9 @@ export interface DashboardCollectorData { visualizationByValue: { [key: string]: number; }; + embeddable: { + [key: string]: number; + }; } export const getEmptyTelemetryData = (): DashboardCollectorData => ({ @@ -48,6 +51,7 @@ export const getEmptyTelemetryData = (): DashboardCollectorData => ({ panelsByValue: 0, lensByValue: {}, visualizationByValue: {}, + embeddable: {}, }); type DashboardCollectorFunction = ( @@ -115,6 +119,23 @@ export const collectForPanels: DashboardCollectorFunction = (panels, collectorDa collectByValueLensInfo(panels, collectorData); }; +export const collectEmbeddableData = ( + panels: SavedDashboardPanel730ToLatest[], + collectorData: DashboardCollectorData, + embeddableService: EmbeddablePersistableStateService +) => { + for (const panel of panels) { + collectorData.embeddable = embeddableService.telemetry( + { + ...panel.embeddableConfig, + id: panel.id || '', + type: panel.type, + }, + collectorData.embeddable + ); + } +}; + export async function collectDashboardTelemetry( savedObjectClient: Pick, embeddableService: EmbeddablePersistableStateService @@ -134,6 +155,7 @@ export async function collectDashboardTelemetry( ) as unknown) as SavedDashboardPanel730ToLatest[]; collectForPanels(panels, collectorData); + collectEmbeddableData(panels, collectorData, embeddableService); } return collectorData; diff --git a/src/plugins/dashboard/server/usage/register_collector.ts b/src/plugins/dashboard/server/usage/register_collector.ts index 780dd716c0f78a..a911fc9b816661 100644 --- a/src/plugins/dashboard/server/usage/register_collector.ts +++ b/src/plugins/dashboard/server/usage/register_collector.ts @@ -27,11 +27,28 @@ export function registerDashboardUsageCollector( lensByValue: { DYNAMIC_KEY: { type: 'long', + _meta: { + description: + 'Collection of telemetry metrics for Lens visualizations, which are added to dashboard by "value".', + }, }, }, visualizationByValue: { DYNAMIC_KEY: { type: 'long', + _meta: { + description: + 'Collection of telemetry metrics for visualizations, which are added to dashboard by "value".', + }, + }, + }, + embeddable: { + DYNAMIC_KEY: { + type: 'long', + _meta: { + description: + 'Collection of telemetry metrics that embeddable service reports. Embeddable service internally calls each embeddable, which in turn calls its dynamic actions, which calls each drill down attached to that embeddable.', + }, }, }, }, diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 3e1a19711d0ff3..4ddef89727ef1d 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -236,7 +236,7 @@ export class EmbeddablePublicPlugin implements Plugin ({}), + telemetry: (state, stats) => stats, inject: identity, extract: (state: SerializableState) => { return { state, references: [] }; diff --git a/src/plugins/embeddable/server/plugin.ts b/src/plugins/embeddable/server/plugin.ts index f4728bf575a06f..788f51adc327b7 100644 --- a/src/plugins/embeddable/server/plugin.ts +++ b/src/plugins/embeddable/server/plugin.ts @@ -90,7 +90,7 @@ export class EmbeddableServerPlugin implements Plugin ({}), + telemetry: (state, stats) => stats, inject: identity, extract: (state: SerializableState) => { return { state, references: [] }; @@ -119,7 +119,7 @@ export class EmbeddableServerPlugin implements Plugin ({}), + telemetry: (state, stats) => stats, inject: (state: EmbeddableStateWithType) => state, extract: (state: EmbeddableStateWithType) => { return { state, references: [] }; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 0ca1b863f91a7b..1d37c25f52fd49 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -11,14 +11,30 @@ "lensByValue": { "properties": { "DYNAMIC_KEY": { - "type": "long" + "type": "long", + "_meta": { + "description": "Collection of telemetry metrics for Lens visualizations, which are added to dashboard by \"value\"." + } } } }, "visualizationByValue": { "properties": { "DYNAMIC_KEY": { - "type": "long" + "type": "long", + "_meta": { + "description": "Collection of telemetry metrics for visualizations, which are added to dashboard by \"value\"." + } + } + } + }, + "embeddable": { + "properties": { + "DYNAMIC_KEY": { + "type": "long", + "_meta": { + "description": "Collection of telemetry metrics that embeddable service reports. Embeddable service internally calls each embeddable, which in turn calls its dynamic actions, which calls each drill down attached to that embeddable." + } } } } diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts index bd5dc5794cb59d..93c1b33268bf4d 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts @@ -123,7 +123,7 @@ export class ActionFactory< } public telemetry(state: SerializedEvent, telemetryData: Record) { - return this.def.telemetry ? this.def.telemetry(state, telemetryData) : {}; + return this.def.telemetry ? this.def.telemetry(state, telemetryData) : telemetryData; } public extract(state: SerializedEvent) { diff --git a/x-pack/plugins/ui_actions_enhanced/server/plugin.ts b/x-pack/plugins/ui_actions_enhanced/server/plugin.ts index 245892e854df2a..3faa5ce6aa3ef4 100644 --- a/x-pack/plugins/ui_actions_enhanced/server/plugin.ts +++ b/x-pack/plugins/ui_actions_enhanced/server/plugin.ts @@ -52,7 +52,7 @@ export class AdvancedUiActionsServerPlugin this.actionFactories.set(definition.id, { id: definition.id, - telemetry: definition.telemetry || (() => ({})), + telemetry: definition.telemetry || ((state, stats) => stats), inject: definition.inject || identity, extract: definition.extract || diff --git a/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts b/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts index 15cb40ee62068c..c89d93f5f5e283 100644 --- a/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts +++ b/x-pack/plugins/ui_actions_enhanced/server/telemetry/dynamic_actions_collector.ts @@ -10,8 +10,9 @@ import { getMetricKey } from './get_metric_key'; export const dynamicActionsCollector = ( state: DynamicActionsState, - stats: Record + currentStats: Record ): Record => { + const stats: Record = { ...currentStats }; const countMetricKey = getMetricKey('count'); stats[countMetricKey] = state.events.length + (stats[countMetricKey] || 0); From c30dc1e08f43299f0d516fb0cbd321abad2743dd Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Thu, 3 Jun 2021 15:15:29 +0300 Subject: [PATCH 35/77] use fake timers to avoid flakiness (#101254) --- .../create_streaming_batched_function.test.ts | 78 ++++++++++--------- 1 file changed, 40 insertions(+), 38 deletions(-) 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 458b691573e56b..719bddc4080d09 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,8 +48,14 @@ const setup = () => { }; }; -// FLAKY: https://github.com/elastic/kibana/issues/101126 -describe.skip('createStreamingBatchedFunction()', () => { +describe('createStreamingBatchedFunction()', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); test('returns a function', () => { const { fetchStreaming } = setup(); const fn = createStreamingBatchedFunction({ @@ -87,8 +93,8 @@ describe.skip('createStreamingBatchedFunction()', () => { expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ baz: 'quix' }); expect(fetchStreaming).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(6); - await new Promise((r) => setTimeout(r, 6)); expect(fetchStreaming).toHaveBeenCalledTimes(1); }); @@ -103,7 +109,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }); expect(fetchStreaming).toHaveBeenCalledTimes(0); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(fetchStreaming).toHaveBeenCalledTimes(0); }); @@ -118,7 +124,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }); fn({ foo: 'bar' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(fetchStreaming.mock.calls[0][0]).toMatchObject({ url: '/test', @@ -139,7 +145,7 @@ describe.skip('createStreamingBatchedFunction()', () => { fn({ foo: 'bar' }); fn({ baz: 'quix' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); const { body } = fetchStreaming.mock.calls[0][0]; expect(JSON.parse(body)).toEqual({ batch: [{ foo: 'bar' }, { baz: 'quix' }], @@ -160,13 +166,10 @@ describe.skip('createStreamingBatchedFunction()', () => { expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ foo: 'bar' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ baz: 'quix' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(0); fn({ full: 'yep' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(1); }); @@ -186,7 +189,7 @@ describe.skip('createStreamingBatchedFunction()', () => { of(fn({ foo: 'bar' }, abortController.signal)); fn({ baz: 'quix' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); const { body } = fetchStreaming.mock.calls[0][0]; expect(JSON.parse(body)).toEqual({ batch: [{ baz: 'quix' }], @@ -206,7 +209,6 @@ describe.skip('createStreamingBatchedFunction()', () => { fn({ a: '1' }); fn({ b: '2' }); fn({ c: '3' }); - await flushPromises(); expect(fetchStreaming.mock.calls[0][0]).toMatchObject({ url: '/test', @@ -231,11 +233,9 @@ describe.skip('createStreamingBatchedFunction()', () => { fn({ a: '1' }); fn({ b: '2' }); fn({ c: '3' }); - await flushPromises(); expect(fetchStreaming).toHaveBeenCalledTimes(1); fn({ d: '4' }); - await flushPromises(); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(fetchStreaming).toHaveBeenCalledTimes(2); }); }); @@ -253,7 +253,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise1)).toBe(true); expect(await isPending(promise2)).toBe(true); @@ -274,7 +274,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); const promise3 = fn({ c: '3' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise1)).toBe(true); expect(await isPending(promise2)).toBe(true); @@ -316,7 +316,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); const promise3 = fn({ c: '3' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -365,7 +365,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = fn({ a: '1' }); const promise2 = fn({ b: '2' }); const promise3 = fn({ c: '3' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -405,7 +405,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }); const promise = fn({ a: '1' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise)).toBe(true); @@ -437,7 +437,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise2 = of(fn({ a: '2' })); const promise3 = of(fn({ a: '3' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -446,7 +446,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); stream.next( JSON.stringify({ @@ -455,7 +455,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); stream.next( JSON.stringify({ @@ -464,7 +464,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [result1] = await promise1; const [, error2] = await promise2; @@ -489,13 +489,14 @@ describe.skip('createStreamingBatchedFunction()', () => { const abortController = new AbortController(); const promise = fn({ a: '1' }, abortController.signal); const promise2 = fn({ a: '2' }, abortController.signal); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise)).toBe(true); expect(await isPending(promise2)).toBe(true); abortController.abort(); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); + await flushPromises(); expect(await isPending(promise)).toBe(false); expect(await isPending(promise2)).toBe(false); @@ -519,12 +520,13 @@ describe.skip('createStreamingBatchedFunction()', () => { const abortController = new AbortController(); const promise = fn({ a: '1' }, abortController.signal); const promise2 = fn({ a: '2' }); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); expect(await isPending(promise)).toBe(true); abortController.abort(); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); + await flushPromises(); expect(await isPending(promise)).toBe(false); const [, error] = await of(promise); @@ -537,7 +539,7 @@ describe.skip('createStreamingBatchedFunction()', () => { }) + '\n' ); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [result2] = await of(promise2); expect(result2).toEqual({ b: '2' }); @@ -558,11 +560,11 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.complete(); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [, error2] = await promise2; @@ -589,7 +591,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -599,7 +601,7 @@ describe.skip('createStreamingBatchedFunction()', () => { ); stream.complete(); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [result1] = await promise2; @@ -627,13 +629,13 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.error({ message: 'something went wrong', }); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [, error2] = await promise2; @@ -660,7 +662,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -670,7 +672,7 @@ describe.skip('createStreamingBatchedFunction()', () => { ); stream.error('oops'); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [result1] = await promise2; @@ -698,7 +700,7 @@ describe.skip('createStreamingBatchedFunction()', () => { const promise1 = of(fn({ a: '1' })); const promise2 = of(fn({ a: '2' })); - await new Promise((r) => setTimeout(r, 6)); + jest.advanceTimersByTime(6); stream.next( JSON.stringify({ @@ -709,7 +711,7 @@ describe.skip('createStreamingBatchedFunction()', () => { stream.next('Not a JSON\n'); - await new Promise((r) => setTimeout(r, 1)); + jest.advanceTimersByTime(1); const [, error1] = await promise1; const [result1] = await promise2; From 2ea4d5713c3ed6324779788dae1a470dcbcc403d Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 3 Jun 2021 15:19:35 +0200 Subject: [PATCH 36/77] [Uptime] Move uptime to new solution nav (#100905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Expose options to customize the route matching * Add more comments * move uptime to new solution nav * push * update test * add an extra breadcrumb Co-authored-by: Felix Stürmer --- x-pack/plugins/uptime/kibana.json | 6 +- x-pack/plugins/uptime/public/apps/plugin.ts | 62 ++++++-- .../plugins/uptime/public/apps/uptime_app.tsx | 1 + .../certificates/certificate_title.tsx | 25 +++ .../header/action_menu_content.test.tsx | 2 +- .../common/header/page_header.test.tsx | 69 --------- .../components/common/header/page_header.tsx | 64 -------- .../components/monitor/monitor_title.test.tsx | 65 -------- .../components/monitor/monitor_title.tsx | 36 +++-- .../synthetics/step_detail/step_detail.tsx | 144 ------------------ .../step_detail/step_detail_container.tsx | 59 ++++--- .../synthetics/step_detail/step_page_nav.tsx | 71 +++++++++ .../step_detail/step_page_title.tsx | 69 +++++++++ .../use_monitor_breadcrumbs.test.tsx | 8 + .../public/hooks/use_breadcrumbs.test.tsx | 16 +- .../uptime/public/hooks/use_breadcrumbs.ts | 40 +++-- .../uptime/public/lib/helper/rtl_helpers.tsx | 8 +- .../uptime/public/pages/certificates.tsx | 25 +-- .../plugins/uptime/public/pages/overview.tsx | 3 +- .../plugins/uptime/public/pages/settings.tsx | 134 ++++++++-------- .../pages/synthetics/synthetics_checks.tsx | 30 ++-- x-pack/plugins/uptime/public/routes.tsx | 82 +++++----- .../services/uptime/certificates.ts | 3 +- .../functional/services/uptime/navigation.ts | 5 +- 24 files changed, 471 insertions(+), 556 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx delete mode 100644 x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx delete mode 100644 x-pack/plugins/uptime/public/components/common/header/page_header.tsx delete mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 0d2346f59b0a12..4d5ab531af7c47 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -8,7 +8,6 @@ "optionalPlugins": [ "data", "home", - "observability", "ml", "fleet" ], @@ -18,7 +17,8 @@ "features", "licensing", "triggersActionsUi", - "usageCollection" + "usageCollection", + "observability" ], "server": true, "ui": true, @@ -31,4 +31,4 @@ "data", "ml" ] -} \ No newline at end of file +} diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index 80a131676951e4..e02cf44b0856eb 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -12,6 +12,8 @@ import { PluginInitializerContext, AppMountParameters, } from 'kibana/public'; +import { of } from 'rxjs'; +import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '../../../../../src/core/public'; import { FeatureCatalogueCategory, @@ -28,7 +30,11 @@ import { } from '../../../../../src/plugins/data/public'; import { alertTypeInitializers } from '../lib/alert_types'; import { FleetStart } from '../../../fleet/public'; -import { FetchDataParams, ObservabilityPublicSetup } from '../../../observability/public'; +import { + FetchDataParams, + ObservabilityPublicSetup, + ObservabilityPublicStart, +} from '../../../observability/public'; import { PLUGIN } from '../../common/constants/plugin'; import { IStorageWrapper } from '../../../../../src/plugins/kibana_utils/public'; import { @@ -48,6 +54,7 @@ export interface ClientPluginsStart { data: DataPublicPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; fleet?: FleetStart; + observability: ObservabilityPublicStart; } export interface UptimePluginServices extends Partial { @@ -83,21 +90,46 @@ export class UptimePlugin return UptimeDataHelper(coreStart); }; - if (plugins.observability) { - plugins.observability.dashboard.register({ - appName: 'synthetics', - hasData: async () => { - const dataHelper = await getUptimeDataHelper(); - const status = await dataHelper.indexStatus(); - return { hasData: status.docCount > 0, indices: status.indices }; - }, - fetchData: async (params: FetchDataParams) => { - const dataHelper = await getUptimeDataHelper(); - return await dataHelper.overviewData(params); - }, - }); - } + plugins.observability.dashboard.register({ + appName: 'synthetics', + hasData: async () => { + const dataHelper = await getUptimeDataHelper(); + const status = await dataHelper.indexStatus(); + return { hasData: status.docCount > 0, indices: status.indices }; + }, + fetchData: async (params: FetchDataParams) => { + const dataHelper = await getUptimeDataHelper(); + return await dataHelper.overviewData(params); + }, + }); + plugins.observability.navigation.registerSections( + of([ + { + label: 'Uptime', + sortKey: 200, + entries: [ + { + label: i18n.translate('xpack.uptime.overview.heading', { + defaultMessage: 'Monitoring overview', + }), + app: 'uptime', + path: '/', + matchFullPath: true, + ignoreTrailingSlash: true, + }, + { + label: i18n.translate('xpack.uptime.certificatesPage.heading', { + defaultMessage: 'TLS Certificates', + }), + app: 'uptime', + path: '/certificates', + matchFullPath: true, + }, + ], + }, + ]) + ); core.application.register({ id: PLUGIN.ID, euiIconType: 'logoObservability', diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 758d40a95a86a2..4d99e877291b5e 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -122,6 +122,7 @@ const Application = (props: UptimeAppProps) => { storage, data: startPlugins.data, triggersActionsUi: startPlugins.triggersActionsUi, + observability: startPlugins.observability, }} > diff --git a/x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx b/x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx new file mode 100644 index 00000000000000..5056a3d1c1957c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/certificates/certificate_title.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useSelector } from 'react-redux'; +import { certificatesSelector } from '../../state/certificates/certificates'; + +export const CertificateTitle = () => { + const { data: certificates } = useSelector(certificatesSelector); + + return ( + {certificates?.total ?? 0}, + }} + /> + ); +}; diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx index bc5eab6f92111e..89d8f38b1e3b3b 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.test.tsx @@ -49,7 +49,7 @@ describe('ActionMenuContent', () => { // this href value is mocked, so it doesn't correspond to the real link // that Kibana core services will provide - expect(addDataAnchor.getAttribute('href')).toBe('/app/uptime'); + expect(addDataAnchor.getAttribute('href')).toBe('/home#/tutorial/uptimeMonitors'); expect(getByText('Add data')); }); }); diff --git a/x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx b/x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx deleted file mode 100644 index 6e04648a817f0c..00000000000000 --- a/x-pack/plugins/uptime/public/components/common/header/page_header.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import moment from 'moment'; -import { PageHeader } from './page_header'; -import { Ping } from '../../../../common/runtime_types'; -import { renderWithRouter } from '../../../lib'; -import { mockReduxHooks } from '../../../lib/helper/test_helpers'; - -describe('PageHeader', () => { - const monitorName = 'sample monitor'; - const defaultMonitorId = 'always-down'; - - const defaultMonitorStatus: Ping = { - docId: 'few213kl', - timestamp: moment(new Date()).subtract(15, 'm').toString(), - monitor: { - duration: { - us: 1234567, - }, - id: defaultMonitorId, - status: 'up', - type: 'http', - name: monitorName, - }, - url: { - full: 'https://www.elastic.co/', - }, - }; - - beforeEach(() => { - mockReduxHooks(defaultMonitorStatus); - }); - - it('does not render dynamic elements by default', () => { - const component = renderWithRouter(); - - expect(component.find('[data-test-subj="superDatePickerShowDatesButton"]').length).toBe(0); - expect(component.find('[data-test-subj="certificatesRefreshButton"]').length).toBe(0); - expect(component.find('[data-test-subj="monitorTitle"]').length).toBe(0); - expect(component.find('[data-test-subj="uptimeTabs"]').length).toBe(0); - }); - - it('shallow renders with the date picker', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="superDatePickerShowDatesButton"]').length).toBe(1); - }); - - it('shallow renders with certificate refresh button', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="certificatesRefreshButton"]').length).toBe(1); - }); - - it('renders monitor title when showMonitorTitle', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="monitorTitle"]').length).toBe(1); - expect(component.find('h1').text()).toBe(monitorName); - }); - - it('renders tabs when showTabs is true', () => { - const component = renderWithRouter(); - expect(component.find('[data-test-subj="uptimeTabs"]').length).toBe(1); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/common/header/page_header.tsx b/x-pack/plugins/uptime/public/components/common/header/page_header.tsx deleted file mode 100644 index 28a133698ae8b3..00000000000000 --- a/x-pack/plugins/uptime/public/components/common/header/page_header.tsx +++ /dev/null @@ -1,64 +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 { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import styled from 'styled-components'; -import { UptimeDatePicker } from '../uptime_date_picker'; -import { SyntheticsCallout } from '../../overview/synthetics_callout'; -import { PageTabs } from './page_tabs'; -import { CertRefreshBtn } from '../../certificates/cert_refresh_btn'; -import { MonitorPageTitle } from '../../monitor/monitor_title'; - -export interface Props { - showCertificateRefreshBtn?: boolean; - showDatePicker?: boolean; - showMonitorTitle?: boolean; - showTabs?: boolean; -} - -const StyledPicker = styled(EuiFlexItem)` - &&& { - @media only screen and (max-width: 1024px) and (min-width: 868px) { - .euiSuperDatePicker__flexWrapper { - width: 500px; - } - } - @media only screen and (max-width: 880px) { - flex-grow: 1; - .euiSuperDatePicker__flexWrapper { - width: calc(100% + 8px); - } - } - } -`; - -export const PageHeader = ({ - showCertificateRefreshBtn = false, - showDatePicker = false, - showMonitorTitle = false, - showTabs = false, -}: Props) => { - return ( - <> - - - - {showMonitorTitle && } - {showTabs && } - - {showCertificateRefreshBtn && } - {showDatePicker && ( - - - - )} - - - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx index 5e77e68720c528..4fd6335c3d3ca9 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_title.test.tsx @@ -48,38 +48,6 @@ describe('MonitorTitle component', () => { }, }; - const defaultTCPMonitorStatus: Ping = { - docId: 'few213kl', - timestamp: moment(new Date()).subtract(15, 'm').toString(), - monitor: { - duration: { - us: 1234567, - }, - id: 'tcp', - status: 'up', - type: 'tcp', - }, - url: { - full: 'https://www.elastic.co/', - }, - }; - - const defaultICMPMonitorStatus: Ping = { - docId: 'few213kl', - timestamp: moment(new Date()).subtract(15, 'm').toString(), - monitor: { - duration: { - us: 1234567, - }, - id: 'icmp', - status: 'up', - type: 'icmp', - }, - url: { - full: 'https://www.elastic.co/', - }, - }; - const defaultBrowserMonitorStatus: Ping = { docId: 'few213kl', timestamp: moment(new Date()).subtract(15, 'm').toString(), @@ -145,37 +113,4 @@ describe('MonitorTitle component', () => { expect(betaLink.href).toBe('https://www.elastic.co/what-is/synthetic-monitoring'); expect(screen.getByText('Browser (BETA)')).toBeInTheDocument(); }); - - it('does not render beta disclaimer for http', () => { - render(, { - state: { monitorStatus: { status: defaultMonitorStatus, loading: false } }, - }); - expect(screen.getByText('HTTP ping')).toBeInTheDocument(); - expect(screen.queryByText(/BETA/)).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'See more External link (opens in a new tab or window)' }) - ).not.toBeInTheDocument(); - }); - - it('does not render beta disclaimer for tcp', () => { - render(, { - state: { monitorStatus: { status: defaultTCPMonitorStatus, loading: false } }, - }); - expect(screen.getByText('TCP ping')).toBeInTheDocument(); - expect(screen.queryByText(/BETA/)).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'See more External link (opens in a new tab or window)' }) - ).not.toBeInTheDocument(); - }); - - it('renders badge and does not render beta disclaimer for icmp', () => { - render(, { - state: { monitorStatus: { status: defaultICMPMonitorStatus, loading: false } }, - }); - expect(screen.getByText('ICMP ping')).toBeInTheDocument(); - expect(screen.queryByText(/BETA/)).not.toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: 'See more External link (opens in a new tab or window)' }) - ).not.toBeInTheDocument(); - }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx index eebd3d8aeb14da..8cb1c49cbd9743 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_title.tsx @@ -5,7 +5,15 @@ * 2.0. */ -import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiLink } from '@elastic/eui'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiLink, + EuiText, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { useSelector } from 'react-redux'; @@ -95,26 +103,26 @@ export const MonitorPageTitle: React.FC = () => { - {type && ( + {isBrowser && type && ( {renderMonitorType(type)}{' '} - {isBrowser && ( - - )} + )} {isBrowser && ( - - - + + + + + )} diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx deleted file mode 100644 index befe53219a4494..00000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail.tsx +++ /dev/null @@ -1,144 +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 { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiTitle, - EuiButtonEmpty, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; -import moment from 'moment'; -import { WaterfallChartContainer } from './waterfall/waterfall_chart_container'; - -export const PREVIOUS_CHECK_BUTTON_TEXT = i18n.translate( - 'xpack.uptime.synthetics.stepDetail.previousCheckButtonText', - { - defaultMessage: 'Previous check', - } -); - -export const NEXT_CHECK_BUTTON_TEXT = i18n.translate( - 'xpack.uptime.synthetics.stepDetail.nextCheckButtonText', - { - defaultMessage: 'Next check', - } -); - -interface Props { - checkGroup: string; - stepName?: string; - stepIndex: number; - totalSteps: number; - hasPreviousStep: boolean; - hasNextStep: boolean; - handlePreviousStep: () => void; - handleNextStep: () => void; - handleNextRun: () => void; - handlePreviousRun: () => void; - previousCheckGroup?: string; - nextCheckGroup?: string; - checkTimestamp?: string; - dateFormat: string; -} - -export const StepDetail: React.FC = ({ - dateFormat, - stepName, - checkGroup, - stepIndex, - totalSteps, - hasPreviousStep, - hasNextStep, - handlePreviousStep, - handleNextStep, - handlePreviousRun, - handleNextRun, - previousCheckGroup, - nextCheckGroup, - checkTimestamp, -}) => { - return ( - <> - - - - - -

{stepName}

-
-
- - - - - - - - - - - - - - - -
-
- - - - - {PREVIOUS_CHECK_BUTTON_TEXT} - - - - {moment(checkTimestamp).format(dateFormat)} - - - - {NEXT_CHECK_BUTTON_TEXT} - - - - -
- - - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx index ef0d001ac905e2..df8f5dff59dc22 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_detail_container.tsx @@ -13,8 +13,12 @@ import { useHistory } from 'react-router-dom'; import { getJourneySteps } from '../../../../state/actions/journey'; import { journeySelector } from '../../../../state/selectors'; import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public'; -import { StepDetail } from './step_detail'; import { useMonitorBreadcrumb } from './use_monitor_breadcrumb'; +import { ClientPluginsStart } from '../../../../apps/plugin'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { StepPageTitle } from './step_page_title'; +import { StepPageNavigation } from './step_page_nav'; +import { WaterfallChartContainer } from './waterfall/waterfall_chart_container'; export const NO_STEP_DATA = i18n.translate('xpack.uptime.synthetics.stepDetail.noData', { defaultMessage: 'No data could be found for this step', @@ -66,8 +70,40 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) history.push(`/journey/${journey?.details?.previous?.checkGroup}/step/1`); }, [history, journey?.details?.previous?.checkGroup]); + const { + services: { observability }, + } = useKibana(); + const PageTemplateComponent = observability.navigation.PageTemplate; + return ( - <> + + ) : null, + rightSideItems: journey + ? [ + , + ] + : [], + }} + > {(!journey || journey.loading) && ( @@ -86,24 +122,9 @@ export const StepDetailContainer: React.FC = ({ checkGroup, stepIndex }) )} {journey && activeStep && !journey.loading && ( - + )} - + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx new file mode 100644 index 00000000000000..81c72b74c18e8f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_nav.tsx @@ -0,0 +1,71 @@ +/* + * 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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; + +export const PREVIOUS_CHECK_BUTTON_TEXT = i18n.translate( + 'xpack.uptime.synthetics.stepDetail.previousCheckButtonText', + { + defaultMessage: 'Previous check', + } +); + +export const NEXT_CHECK_BUTTON_TEXT = i18n.translate( + 'xpack.uptime.synthetics.stepDetail.nextCheckButtonText', + { + defaultMessage: 'Next check', + } +); + +interface Props { + previousCheckGroup?: string; + dateFormat: string; + checkTimestamp?: string; + nextCheckGroup?: string; + handlePreviousRun: () => void; + handleNextRun: () => void; +} +export const StepPageNavigation = ({ + previousCheckGroup, + dateFormat, + handleNextRun, + handlePreviousRun, + checkTimestamp, + nextCheckGroup, +}: Props) => { + return ( + + + + {PREVIOUS_CHECK_BUTTON_TEXT} + + + + {moment(checkTimestamp).format(dateFormat)} + + + + {NEXT_CHECK_BUTTON_TEXT} + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx new file mode 100644 index 00000000000000..083f2f1533e2e9 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/step_page_title.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + stepName?: string; + stepIndex: number; + totalSteps: number; + hasPreviousStep: boolean; + hasNextStep: boolean; + handlePreviousStep: () => void; + handleNextStep: () => void; +} +export const StepPageTitle = ({ + stepName, + stepIndex, + totalSteps, + handleNextStep, + handlePreviousStep, + hasNextStep, + hasPreviousStep, +}: Props) => { + return ( + + + +

{stepName}

+
+
+ + + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx index 4aed073424788a..4521d9f82f92e4 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_monitor_breadcrumbs.test.tsx @@ -63,6 +63,10 @@ describe('useMonitorBreadcrumbs', () => { expect(getBreadcrumbs()).toMatchInlineSnapshot(` Array [ + Object { + "href": "", + "text": "Observability", + }, Object { "href": "/app/uptime", "onClick": [Function], @@ -129,6 +133,10 @@ describe('useMonitorBreadcrumbs', () => { expect(getBreadcrumbs()).toMatchInlineSnapshot(` Array [ + Object { + "href": "", + "text": "Observability", + }, Object { "href": "/app/uptime", "onClick": [Function], diff --git a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx index 6fc98fbaf1f5b0..9d7318a45f76e5 100644 --- a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx +++ b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.test.tsx @@ -19,14 +19,8 @@ describe('useBreadcrumbs', () => { const [getBreadcrumbs, core] = mockCore(); const expectedCrumbs: ChromeBreadcrumb[] = [ - { - text: 'Crumb: ', - href: 'http://href.example.net', - }, - { - text: 'Crumb II: Son of Crumb', - href: 'http://href2.example.net', - }, + { text: 'Crumb: ', href: 'http://href.example.net' }, + { text: 'Crumb II: Son of Crumb', href: 'http://href2.example.net' }, ]; const Component = () => { @@ -46,7 +40,9 @@ describe('useBreadcrumbs', () => { const urlParams: UptimeUrlParams = getSupportedUrlParams({}); expect(JSON.stringify(getBreadcrumbs())).toEqual( - JSON.stringify([makeBaseBreadcrumb('/app/uptime', urlParams)].concat(expectedCrumbs)) + JSON.stringify( + makeBaseBreadcrumb('/app/uptime', '/app/observability', urlParams).concat(expectedCrumbs) + ) ); }); }); @@ -58,7 +54,7 @@ const mockCore: () => [() => ChromeBreadcrumb[], any] = () => { }; const core = { application: { - getUrlForApp: () => '/app/uptime', + getUrlForApp: (app: string) => (app === 'uptime' ? '/app/uptime' : '/app/observability'), navigateToUrl: jest.fn(), }, chrome: { diff --git a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts index f2ec25b50332b2..5ea81e579ff920 100644 --- a/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts +++ b/x-pack/plugins/uptime/public/hooks/use_breadcrumbs.ts @@ -36,34 +36,52 @@ function handleBreadcrumbClick( })); } -export const makeBaseBreadcrumb = (href: string, params?: UptimeUrlParams): EuiBreadcrumb => { +export const makeBaseBreadcrumb = ( + uptimePath: string, + observabilityPath: string, + params?: UptimeUrlParams +): [EuiBreadcrumb, EuiBreadcrumb] => { if (params) { const crumbParams: Partial = { ...params }; delete crumbParams.statusFilter; const query = stringifyUrlParams(crumbParams, true); - href += query === EMPTY_QUERY ? '' : query; + uptimePath += query === EMPTY_QUERY ? '' : query; } - return { - text: i18n.translate('xpack.uptime.breadcrumbs.overviewBreadcrumbText', { - defaultMessage: 'Uptime', - }), - href, - }; + + return [ + { + text: i18n.translate('xpack.uptime.breadcrumbs.observabilityText', { + defaultMessage: 'Observability', + }), + href: observabilityPath, + }, + { + text: i18n.translate('xpack.uptime.breadcrumbs.overviewBreadcrumbText', { + defaultMessage: 'Uptime', + }), + href: uptimePath, + }, + ]; }; export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => { const params = useUrlParams()[0](); const kibana = useKibana(); const setBreadcrumbs = kibana.services.chrome?.setBreadcrumbs; - const appPath = kibana.services.application?.getUrlForApp(PLUGIN.ID) ?? ''; + const uptimePath = kibana.services.application?.getUrlForApp(PLUGIN.ID) ?? ''; + const observabilityPath = + kibana.services.application?.getUrlForApp('observability-overview') ?? ''; const navigate = kibana.services.application?.navigateToUrl; useEffect(() => { if (setBreadcrumbs) { setBreadcrumbs( - handleBreadcrumbClick([makeBaseBreadcrumb(appPath, params)].concat(extraCrumbs), navigate) + handleBreadcrumbClick( + makeBaseBreadcrumb(uptimePath, observabilityPath, params).concat(extraCrumbs), + navigate + ) ); } - }, [appPath, extraCrumbs, navigate, params, setBreadcrumbs]); + }, [uptimePath, observabilityPath, extraCrumbs, navigate, params, setBreadcrumbs]); }; diff --git a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx index a84209a23449ae..0c2e31589bb100 100644 --- a/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx +++ b/x-pack/plugins/uptime/public/lib/helper/rtl_helpers.tsx @@ -79,6 +79,12 @@ const createMockStore = () => { }; }; +const mockAppUrls: Record = { + uptime: '/app/uptime', + observability: '/app/observability', + '/home#/tutorial/uptimeMonitors': '/home#/tutorial/uptimeMonitors', +}; + /* default mock core */ const defaultCore = coreMock.createStart(); const mockCore: () => Partial = () => { @@ -86,7 +92,7 @@ const mockCore: () => Partial = () => { ...defaultCore, application: { ...defaultCore.application, - getUrlForApp: () => '/app/uptime', + getUrlForApp: (app: string) => mockAppUrls[app], navigateToUrl: jest.fn(), capabilities: { ...defaultCore.application.capabilities, diff --git a/x-pack/plugins/uptime/public/pages/certificates.tsx b/x-pack/plugins/uptime/public/pages/certificates.tsx index 7c21493dbde061..4b8617d5945477 100644 --- a/x-pack/plugins/uptime/public/pages/certificates.tsx +++ b/x-pack/plugins/uptime/public/pages/certificates.tsx @@ -5,15 +5,14 @@ * 2.0. */ -import { useDispatch, useSelector } from 'react-redux'; -import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useDispatch } from 'react-redux'; +import { EuiSpacer } from '@elastic/eui'; import React, { useContext, useEffect, useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; import { useTrackPageview } from '../../../observability/public'; import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { getDynamicSettings } from '../state/actions/dynamic_settings'; import { UptimeRefreshContext } from '../contexts'; -import { certificatesSelector, getCertificatesAction } from '../state/certificates/certificates'; +import { getCertificatesAction } from '../state/certificates/certificates'; import { CertificateList, CertificateSearch, CertSort } from '../components/certificates'; const DEFAULT_PAGE_SIZE = 10; @@ -58,22 +57,8 @@ export const CertificatesPage: React.FC = () => { ); }, [dispatch, page, search, sort.direction, sort.field, lastRefresh]); - const { data: certificates } = useSelector(certificatesSelector); - return ( - - -

- {certificates?.total ?? 0}, - }} - /> -

-
- + <> @@ -86,6 +71,6 @@ export const CertificatesPage: React.FC = () => { }} sort={sort} /> -
+ ); }; diff --git a/x-pack/plugins/uptime/public/pages/overview.tsx b/x-pack/plugins/uptime/public/pages/overview.tsx index 846698bc390dba..626e797bd9fd15 100644 --- a/x-pack/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/plugins/uptime/public/pages/overview.tsx @@ -15,6 +15,7 @@ import { MonitorList } from '../components/overview/monitor_list/monitor_list_co import { EmptyState, FilterGroup } from '../components/overview'; import { StatusPanel } from '../components/overview/status_panel'; import { QueryBar } from '../components/overview/query_bar/query_bar'; +import { MONITORING_OVERVIEW_LABEL } from '../routes'; const EuiFlexItemStyled = styled(EuiFlexItem)` && { @@ -32,7 +33,7 @@ export const OverviewPageComponent = () => { useTrackPageview({ app: 'uptime', path: 'overview' }); useTrackPageview({ app: 'uptime', path: 'overview', delay: 15000 }); - useBreadcrumbs([]); // No extra breadcrumbs on overview + useBreadcrumbs([{ text: MONITORING_OVERVIEW_LABEL }]); // No extra breadcrumbs on overview return ( diff --git a/x-pack/plugins/uptime/public/pages/settings.tsx b/x-pack/plugins/uptime/public/pages/settings.tsx index f806ebbd09cc35..5f2699240425a6 100644 --- a/x-pack/plugins/uptime/public/pages/settings.tsx +++ b/x-pack/plugins/uptime/public/pages/settings.tsx @@ -148,73 +148,71 @@ export const SettingsPage: React.FC = () => { ); return ( - <> - - - {cannotEditNotice} - - - -
- - - - - - - - - { - resetForm(); - }} - > - - - - - - - - - - -
-
-
-
- + + + {cannotEditNotice} + + + +
+ + + + + + + + + { + resetForm(); + }} + > + + + + + + + + + + +
+
+
+
); }; diff --git a/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx b/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx index edfd7ae24f91b7..fe41e72fa4c484 100644 --- a/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx +++ b/x-pack/plugins/uptime/public/pages/synthetics/synthetics_checks.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { useTrackPageview } from '../../../../observability/public'; import { useInitApp } from '../../hooks/use_init_app'; import { StepsList } from '../../components/synthetics/check_steps/steps_list'; @@ -14,6 +14,7 @@ import { useCheckSteps } from '../../components/synthetics/check_steps/use_check import { ChecksNavigation } from './checks_navigation'; import { useMonitorBreadcrumb } from '../../components/monitor/synthetics/step_detail/use_monitor_breadcrumb'; import { EmptyJourney } from '../../components/synthetics/empty_journey'; +import { ClientPluginsStart } from '../../apps/plugin'; export const SyntheticsCheckSteps: React.FC = () => { useInitApp(); @@ -24,21 +25,22 @@ export const SyntheticsCheckSteps: React.FC = () => { useMonitorBreadcrumb({ details, activeStep: details?.journey }); + const { + services: { observability }, + } = useKibana(); + const PageTemplateComponent = observability.navigation.PageTemplate; + return ( - <> - - - -

{details?.journey?.monitor.name || details?.journey?.monitor.id}

-
-
- - {details && } - -
- + : null, + ], + }} + > {(!steps || steps.length === 0) && !loading && } - + ); }; diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index 1c025edd0a73d4..192b5552fea40a 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -6,8 +6,9 @@ */ import React, { FC, useEffect } from 'react'; -import { Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { Props as PageHeaderProps, PageHeader } from './components/common/header/page_header'; +import { Route, Switch } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { CERTIFICATES_ROUTE, MONITOR_ROUTE, @@ -21,6 +22,13 @@ import { CertificatesPage } from './pages/certificates'; import { UptimePage, useUptimeTelemetry } from './hooks'; import { OverviewPageComponent } from './pages/overview'; import { SyntheticsCheckSteps } from './pages/synthetics/synthetics_checks'; +import { ClientPluginsStart } from './apps/plugin'; +import { MonitorPageTitle } from './components/monitor/monitor_title'; +import { UptimeDatePicker } from './components/common/uptime_date_picker'; +import { useKibana } from '../../../../src/plugins/kibana_react/public'; +import { CertRefreshBtn } from './components/certificates/cert_refresh_btn'; +import { CertificateTitle } from './components/certificates/certificate_title'; +import { SyntheticsCallout } from './components/overview/synthetics_callout'; interface RouteProps { path: string; @@ -28,11 +36,15 @@ interface RouteProps { dataTestSubj: string; title: string; telemetryId: UptimePage; - headerProps?: PageHeaderProps; + pageHeader?: { pageTitle: string | JSX.Element; rightSideItems?: JSX.Element[] }; } const baseTitle = 'Uptime - Kibana'; +export const MONITORING_OVERVIEW_LABEL = i18n.translate('xpack.uptime.overview.heading', { + defaultMessage: 'Monitoring overview', +}); + const Routes: RouteProps[] = [ { title: `Monitor | ${baseTitle}`, @@ -40,9 +52,9 @@ const Routes: RouteProps[] = [ component: MonitorPage, dataTestSubj: 'uptimeMonitorPage', telemetryId: UptimePage.Monitor, - headerProps: { - showDatePicker: true, - showMonitorTitle: true, + pageHeader: { + pageTitle: , + rightSideItems: [], }, }, { @@ -51,8 +63,10 @@ const Routes: RouteProps[] = [ component: SettingsPage, dataTestSubj: 'uptimeSettingsPage', telemetryId: UptimePage.Settings, - headerProps: { - showTabs: true, + pageHeader: { + pageTitle: ( + + ), }, }, { @@ -61,9 +75,9 @@ const Routes: RouteProps[] = [ component: CertificatesPage, dataTestSubj: 'uptimeCertificatesPage', telemetryId: UptimePage.Certificates, - headerProps: { - showCertificateRefreshBtn: true, - showTabs: true, + pageHeader: { + pageTitle: , + rightSideItems: [], }, }, { @@ -86,9 +100,9 @@ const Routes: RouteProps[] = [ component: OverviewPageComponent, dataTestSubj: 'uptimeOverviewPage', telemetryId: UptimePage.Overview, - headerProps: { - showDatePicker: true, - showTabs: true, + pageHeader: { + pageTitle: MONITORING_OVERVIEW_LABEL, + rightSideItems: [], }, }, ]; @@ -106,31 +120,31 @@ const RouteInit: React.FC> = }; export const PageRouter: FC = () => { + const { + services: { observability }, + } = useKibana(); + const PageTemplateComponent = observability.navigation.PageTemplate; + return ( - <> - {/* Independent page header route that matches all paths and passes appropriate header props */} - {/* Prevents the header from being remounted on route changes */} - route.path)]} - exact={true} - render={({ match }: RouteComponentProps) => { - const routeProps: RouteProps | undefined = Routes.find( - (route: RouteProps) => route?.path === match?.path - ); - return routeProps?.headerProps && ; - }} - /> - - {Routes.map(({ title, path, component: RouteComponent, dataTestSubj, telemetryId }) => ( + + {Routes.map( + ({ title, path, component: RouteComponent, dataTestSubj, telemetryId, pageHeader }) => (
+ - + {pageHeader ? ( + + + + ) : ( + + )}
- ))} - -
- + ) + )} + +
); }; diff --git a/x-pack/test/functional/services/uptime/certificates.ts b/x-pack/test/functional/services/uptime/certificates.ts index 498e18de8e281a..3a560acee52d8a 100644 --- a/x-pack/test/functional/services/uptime/certificates.ts +++ b/x-pack/test/functional/services/uptime/certificates.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeCertProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const find = getService('find'); const PageObjects = getPageObjects(['common', 'timePicker', 'header']); @@ -27,7 +28,7 @@ export function UptimeCertProvider({ getService, getPageObjects }: FtrProviderCo return { async hasViewCertButton() { return retry.tryForTime(15000, async () => { - await testSubjects.existOrFail('uptimeCertificatesLink'); + await find.existsByCssSelector('[href="/app/uptime/certificates"]'); }); }, async certificateExists(cert: { certId: string; monitorId: string }) { diff --git a/x-pack/test/functional/services/uptime/navigation.ts b/x-pack/test/functional/services/uptime/navigation.ts index b76d68e1eb4546..51806d1006ab4e 100644 --- a/x-pack/test/functional/services/uptime/navigation.ts +++ b/x-pack/test/functional/services/uptime/navigation.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); + const find = getService('find'); const PageObjects = getPageObjects(['common', 'timePicker', 'header']); const goToUptimeRoot = async () => { @@ -70,8 +71,8 @@ export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProv goToCertificates: async () => { if (!(await testSubjects.exists('uptimeCertificatesPage', { timeout: 0 }))) { return retry.try(async () => { - if (await testSubjects.exists('uptimeCertificatesLink', { timeout: 0 })) { - await testSubjects.click('uptimeCertificatesLink', 10000); + if (await find.existsByCssSelector('[href="/app/uptime/certificates"]', 0)) { + await find.clickByCssSelector('[href="/app/uptime/certificates"]'); } await testSubjects.existOrFail('uptimeCertificatesPage'); }); From f5df40a5a1fa4b744ee35c2a0ae44e2ffa6ebb16 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 19 May 2021 09:34:14 -0700 Subject: [PATCH 37/77] skip flaky suite (#99581) --- x-pack/test/functional/apps/spaces/spaces_selection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/spaces/spaces_selection.ts b/x-pack/test/functional/apps/spaces/spaces_selection.ts index 99efdf29eecb97..f3d3665bf9f613 100644 --- a/x-pack/test/functional/apps/spaces/spaces_selection.ts +++ b/x-pack/test/functional/apps/spaces/spaces_selection.ts @@ -22,7 +22,8 @@ export default function spaceSelectorFunctionalTests({ 'spaceSelector', ]); - describe('Spaces', function () { + // FLAKY: https://github.com/elastic/kibana/issues/99581 + describe.skip('Spaces', function () { this.tags('includeFirefox'); describe('Space Selector', () => { before(async () => { From b8c127c18fbf70ba0c6b73f9f86bcef5d712beee Mon Sep 17 00:00:00 2001 From: ymao1 Date: Thu, 3 Jun 2021 09:35:27 -0400 Subject: [PATCH 38/77] Fixing pagerduty server side functionality (#101091) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../builtin_action_types/pagerduty.test.ts | 206 +++++++++++++++++- .../server/builtin_action_types/pagerduty.ts | 17 +- 2 files changed, 213 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts index 93c5dd4a44db0d..7540785714bcde 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -152,14 +152,15 @@ describe('validateParams()', () => { `); }); - test('should validate and throw error when timestamp has spaces', () => { + test('should validate and pass when valid timestamp has spaces', () => { const randoDate = new Date('1963-09-23T01:23:45Z').toISOString(); const timestamp = ` ${randoDate}`; - expect(() => { - validateParams(actionType, { - timestamp, - }); - }).toThrowError(`error validating action params: error parsing timestamp "${timestamp}"`); + expect(validateParams(actionType, { timestamp })).toEqual({ timestamp }); + }); + + test('should validate and pass when timestamp is empty string', () => { + const timestamp = ''; + expect(validateParams(actionType, { timestamp })).toEqual({ timestamp }); }); test('should validate and throw error when timestamp is invalid', () => { @@ -409,7 +410,7 @@ describe('execute()', () => { `); }); - test('should fail when sendPagerdury throws', async () => { + test('should fail when sendPagerduty throws', async () => { const secrets = { routingKey: 'super-secret' }; const config = { apiUrl: null }; const params = {}; @@ -576,4 +577,195 @@ describe('execute()', () => { } `); }); + + test('should succeed when timestamp contains valid date and extraneous spaces', async () => { + const randoDate = new Date('1963-09-23T01:23:45Z').toISOString(); + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params: ActionParamsType = { + eventAction: 'trigger', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: ` ${randoDate} `, + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: PagerDutyActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "trigger", + "payload": Object { + "class": "the-class", + "component": "the-component", + "group": "the-group", + "severity": "critical", + "source": "the-source", + "summary": "the summary", + "timestamp": "1963-09-23T01:23:45.000Z", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should not pass timestamp field when timestamp is empty string', async () => { + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params: ActionParamsType = { + eventAction: 'trigger', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: '', + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: PagerDutyActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "trigger", + "payload": Object { + "class": "the-class", + "component": "the-component", + "group": "the-group", + "severity": "critical", + "source": "the-source", + "summary": "the summary", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should not pass timestamp field when timestamp is string of spaces', async () => { + const secrets = { + routingKey: 'super-secret', + }; + const config = { + apiUrl: 'the-api-url', + }; + const params: ActionParamsType = { + eventAction: 'trigger', + dedupKey: 'a-dedup-key', + summary: 'the summary', + source: 'the-source', + severity: 'critical', + timestamp: ' ', + component: 'the-component', + group: 'the-group', + class: 'the-class', + }; + + postPagerdutyMock.mockImplementation(() => { + return { status: 202, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: PagerDutyActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postPagerdutyMock.mock.calls[0][0]; + expect({ apiUrl, data, headers }).toMatchInlineSnapshot(` + Object { + "apiUrl": "the-api-url", + "data": Object { + "dedup_key": "a-dedup-key", + "event_action": "trigger", + "payload": Object { + "class": "the-class", + "component": "the-component", + "group": "the-group", + "severity": "critical", + "source": "the-source", + "summary": "the summary", + }, + }, + "headers": Object { + "Content-Type": "application/json", + "X-Routing-Key": "super-secret", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "data": "data-here", + "status": "ok", + } + `); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index b64cf6ec346d56..5d83b658111e48 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -85,11 +85,19 @@ const ParamsSchema = schema.object( { validate: validateParams } ); +function validateTimestamp(timestamp?: string): string | null { + if (timestamp) { + return timestamp.trim().length > 0 ? timestamp.trim() : null; + } + return null; +} + function validateParams(paramsObject: unknown): string | void { const { timestamp, eventAction, dedupKey } = paramsObject as ActionParamsType; - if (timestamp != null) { + const validatedTimestamp = validateTimestamp(timestamp); + if (validatedTimestamp != null) { try { - const date = Date.parse(timestamp); + const date = Date.parse(validatedTimestamp); if (isNaN(date)) { return i18n.translate('xpack.actions.builtin.pagerduty.invalidTimestampErrorMessage', { defaultMessage: `error parsing timestamp "{timestamp}"`, @@ -279,11 +287,14 @@ function getBodyForEventAction(actionId: string, params: ActionParamsType): Page return data; } + const validatedTimestamp = validateTimestamp(params.timestamp); + data.payload = { summary: params.summary || 'No summary provided.', source: params.source || `Kibana Action ${actionId}`, severity: params.severity || 'info', - ...omitBy(pick(params, ['timestamp', 'component', 'group', 'class']), isUndefined), + ...(validatedTimestamp ? { timestamp: validatedTimestamp } : {}), + ...omitBy(pick(params, ['component', 'group', 'class']), isUndefined), }; return data; From de85036fc75a5dcab3d4074f7db65e88e91aee9c Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Thu, 3 Jun 2021 17:47:54 +0300 Subject: [PATCH 39/77] [Usage] Fix flaky UI Counters test (#100979) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apis/ui_counters/ui_counters.ts | 87 ++++++++++--------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index ab3ca2e8dd3a7b..2be6ea4341fb08 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -15,6 +15,7 @@ import { UsageCountersSavedObject } from '../../../../src/plugins/usage_collecti export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const retry = getService('retry'); const createUiCounterEvent = (eventName: string, type: UiCounterMetricType, count = 1) => ({ eventName, @@ -23,16 +24,24 @@ export default function ({ getService }: FtrProviderContext) { count, }); - const sendReport = async (report: Report) => { + const fetchUsageCountersObjects = async (): Promise => { + const { + body: { saved_objects: savedObjects }, + } = await supertest + .get('/api/saved_objects/_find?type=usage-counters') + .set('kbn-xsrf', 'kibana') + .expect(200); + + return savedObjects; + }; + + const sendReport = async (report: Report): Promise => { await supertest .post('/api/ui_counters/_report') .set('kbn-xsrf', 'kibana') .set('content-type', 'application/json') .send({ report }) .expect(200); - - // wait for SO to index data into ES - await new Promise((res) => setTimeout(res, 5 * 1000)); }; const getCounterById = ( @@ -47,8 +56,7 @@ export default function ({ getService }: FtrProviderContext) { return savedObject; }; - // FLAKY: https://github.com/elastic/kibana/issues/98240 - describe.skip('UI Counters API', () => { + describe('UI Counters API', () => { const dayDate = moment().format('DDMMYYYY'); before(async () => await esArchiver.emptyKibanaIndex()); @@ -61,18 +69,15 @@ export default function ({ getService }: FtrProviderContext) { await sendReport(report); - const { - body: { saved_objects: savedObjects }, - } = await supertest - .get('/api/saved_objects/_find?type=usage-counters') - .set('kbn-xsrf', 'kibana') - .expect(200); - - const countTypeEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:my_event` - ); - expect(countTypeEvent.attributes.count).to.eql(1); + await retry.waitForWithTimeout('reported events to be stored into ES', 8000, async () => { + const savedObjects = await fetchUsageCountersObjects(); + const countTypeEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:my_event` + ); + expect(countTypeEvent.attributes.count).to.eql(1); + return true; + }); }); it('supports multiple events', async () => { @@ -87,31 +92,27 @@ export default function ({ getService }: FtrProviderContext) { ]); await sendReport(report); - - const { - body: { saved_objects: savedObjects }, - } = await supertest - .get('/api/saved_objects/_find?type=usage-counters&fields=count') - .set('kbn-xsrf', 'kibana') - .expect(200); - - const countTypeEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}` - ); - expect(countTypeEvent.attributes.count).to.eql(1); - - const clickTypeEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.CLICK}:myApp:${uniqueEventName}` - ); - expect(clickTypeEvent.attributes.count).to.eql(2); - - const secondEvent = getCounterById( - savedObjects, - `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}_2` - ); - expect(secondEvent.attributes.count).to.eql(1); + await retry.waitForWithTimeout('reported events to be stored into ES', 8000, async () => { + const savedObjects = await fetchUsageCountersObjects(); + const countTypeEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}` + ); + expect(countTypeEvent.attributes.count).to.eql(1); + + const clickTypeEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.CLICK}:myApp:${uniqueEventName}` + ); + expect(clickTypeEvent.attributes.count).to.eql(2); + + const secondEvent = getCounterById( + savedObjects, + `uiCounter:${dayDate}:${METRIC_TYPE.COUNT}:myApp:${uniqueEventName}_2` + ); + expect(secondEvent.attributes.count).to.eql(1); + return true; + }); }); }); } From 58b1416f845cdcdfa615533d8c09bbc30a4624a1 Mon Sep 17 00:00:00 2001 From: Oleksiy Kovyrin Date: Thu, 3 Jun 2021 10:58:11 -0400 Subject: [PATCH 40/77] Enterpise Search SSL Settings Support (#100946) Introduce a new set of SSL configuration settings for Enterprise Search plugin, allowing users to configure a set of custom certificate authorities and to control TLS validation mode used for all requests to Enterprise Search. Co-authored-by: Byron Hulcher Co-authored-by: Constance Chen --- .../server/__mocks__/http_agent.mock.ts | 14 +++ .../server/__mocks__/index.ts | 2 + .../__mocks__/routerDependencies.mock.ts | 1 + .../plugins/enterprise_search/server/index.ts | 9 ++ .../lib/enterprise_search_config_api.test.ts | 1 + .../lib/enterprise_search_config_api.ts | 9 +- .../lib/enterprise_search_http_agent.test.ts | 118 ++++++++++++++++++ .../lib/enterprise_search_http_agent.ts | 85 +++++++++++++ .../enterprise_search_request_handler.test.ts | 3 +- .../lib/enterprise_search_request_handler.ts | 15 ++- .../enterprise_search/server/plugin.ts | 6 + 11 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts new file mode 100644 index 00000000000000..1e9b04674b582b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/__mocks__/http_agent.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mockHttpAgent = jest.fn(); + +jest.mock('../lib/enterprise_search_http_agent', () => ({ + entSearchHttpAgent: { + getHttpAgent: () => mockHttpAgent, + }, +})); diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/index.ts b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts index c36acd2b576470..c59a5a8f67e327 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/index.ts @@ -12,3 +12,5 @@ export { mockRequestHandler, mockDependencies, } from './routerDependencies.mock'; + +export { mockHttpAgent } from './http_agent.mock'; diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts index 50ff082858fc8c..08be1a134ae024 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/routerDependencies.mock.ts @@ -23,6 +23,7 @@ export const mockConfig = { host: 'http://localhost:3002', accessCheckTimeout: 5000, accessCheckTimeoutWarning: 300, + ssl: {}, } as ConfigType; /** diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index c4552b9134eae9..ecd068c8bdbd96 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -19,6 +19,15 @@ export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), accessCheckTimeout: schema.number({ defaultValue: 5000 }), accessCheckTimeoutWarning: schema.number({ defaultValue: 300 }), + ssl: schema.object({ + certificateAuthorities: schema.maybe( + schema.oneOf([schema.arrayOf(schema.string(), { minSize: 1 }), schema.string()]) + ), + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + }), }); export type ConfigType = TypeOf; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts index 66f2bf78e0c9c3..50bac793ee6965 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.test.ts @@ -6,6 +6,7 @@ */ import { DEFAULT_INITIAL_APP_DATA } from '../../common/__mocks__'; +import '../__mocks__/http_agent.mock.ts'; jest.mock('node-fetch'); import fetch from 'node-fetch'; diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index 0f2faf1fd8a3ab..8cce01d1932eee 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -16,6 +16,8 @@ import { stripTrailingSlash } from '../../common/strip_slashes'; import { InitialAppData } from '../../common/types'; import { ConfigType } from '../index'; +import { entSearchHttpAgent } from './enterprise_search_http_agent'; + interface Params { request: KibanaRequest; config: ConfigType; @@ -54,10 +56,13 @@ export const callEnterpriseSearchConfigAPI = async ({ try { const enterpriseSearchUrl = encodeURI(`${config.host}${ENDPOINT}`); - const response = await fetch(enterpriseSearchUrl, { + const options = { headers: { Authorization: request.headers.authorization as string }, signal: controller.signal, - }); + agent: entSearchHttpAgent.getHttpAgent(), + }; + + const response = await fetch(enterpriseSearchUrl, options); const data = await response.json(); warnMismatchedVersions(data?.version?.number, log); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts new file mode 100644 index 00000000000000..f4bdfd8d2cb0f2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.test.ts @@ -0,0 +1,118 @@ +/* + * 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. + */ + +jest.mock('fs', () => ({ readFileSync: jest.fn() })); +import { readFileSync } from 'fs'; + +import http from 'http'; +import https from 'https'; + +import { ConfigType } from '../'; + +import { entSearchHttpAgent } from './enterprise_search_http_agent'; + +describe('entSearchHttpAgent', () => { + describe('initializeHttpAgent', () => { + it('creates an https.Agent when host URL is using HTTPS', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: 'https://example.org', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(https.Agent); + }); + + it('creates an http.Agent when host URL is using HTTP', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: 'http://example.org', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + + describe('fallbacks', () => { + it('initializes a http.Agent when host URL is invalid', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: '##!notarealurl#$', + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + + it('should be an http.Agent when host URL is empty', () => { + entSearchHttpAgent.initializeHttpAgent({ + host: undefined, + ssl: {}, + } as ConfigType); + expect(entSearchHttpAgent.getHttpAgent()).toBeInstanceOf(http.Agent); + }); + }); + }); + + describe('loadCertificateAuthorities', () => { + describe('happy path', () => { + beforeEach(() => { + jest.clearAllMocks(); + (readFileSync as jest.Mock).mockImplementation((path: string) => `content-of-${path}`); + }); + + it('reads certificate authorities when ssl.certificateAuthorities is a string', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities('some-path'); + expect(readFileSync).toHaveBeenCalledTimes(1); + expect(certs).toEqual(['content-of-some-path']); + }); + + it('reads certificate authorities when ssl.certificateAuthorities is an array', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities(['some-path', 'another-path']); + expect(readFileSync).toHaveBeenCalledTimes(2); + expect(certs).toEqual(['content-of-some-path', 'content-of-another-path']); + }); + + it('does not read anything when ssl.certificateAuthorities is empty', () => { + const certs = entSearchHttpAgent.loadCertificateAuthorities(undefined); + expect(readFileSync).toHaveBeenCalledTimes(0); + expect(certs).toEqual([]); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + const realFs = jest.requireActual('fs'); + (readFileSync as jest.Mock).mockImplementation((path: string) => realFs.readFileSync(path)); + }); + + it('throws if certificateAuthorities is invalid', () => { + expect(() => entSearchHttpAgent.loadCertificateAuthorities('/invalid/ca')).toThrow( + "ENOENT: no such file or directory, open '/invalid/ca'" + ); + }); + }); + }); + + describe('getAgentOptions', () => { + it('verificationMode: none', () => { + expect(entSearchHttpAgent.getAgentOptions('none')).toEqual({ + rejectUnauthorized: false, + }); + }); + + it('verificationMode: certificate', () => { + expect(entSearchHttpAgent.getAgentOptions('certificate')).toEqual({ + rejectUnauthorized: true, + checkServerIdentity: expect.any(Function), + }); + + const { checkServerIdentity } = entSearchHttpAgent.getAgentOptions('certificate') as any; + expect(checkServerIdentity()).toEqual(undefined); + }); + + it('verificationMode: full', () => { + expect(entSearchHttpAgent.getAgentOptions('full')).toEqual({ + rejectUnauthorized: true, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts new file mode 100644 index 00000000000000..89210def248b31 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_http_agent.ts @@ -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 { readFileSync } from 'fs'; +import http from 'http'; +import https from 'https'; +import { PeerCertificate } from 'tls'; + +import { ConfigType } from '../'; + +export type HttpAgent = http.Agent | https.Agent; +interface AgentOptions { + rejectUnauthorized?: boolean; + checkServerIdentity?: ((host: string, cert: PeerCertificate) => Error | undefined) | undefined; +} + +/* + * Returns an HTTP agent to be used for requests to Enterprise Search APIs + */ +class EnterpriseSearchHttpAgent { + public httpAgent: HttpAgent = new http.Agent(); + + getHttpAgent() { + return this.httpAgent; + } + + initializeHttpAgent(config: ConfigType) { + if (!config.host) return; + + try { + const parsedHost = new URL(config.host); + if (parsedHost.protocol === 'https:') { + this.httpAgent = new https.Agent({ + ca: this.loadCertificateAuthorities(config.ssl.certificateAuthorities), + ...this.getAgentOptions(config.ssl.verificationMode), + }); + } + } catch { + // Ignore URL parsing errors and fall back to the HTTP agent + } + } + + /* + * Loads custom CA certificate files and returns all certificates as an array + * This is a potentially expensive operation & why this helper is a class + * initialized once on plugin init + */ + loadCertificateAuthorities(certificates: string | string[] | undefined): string[] { + if (!certificates) return []; + + const paths = Array.isArray(certificates) ? certificates : [certificates]; + return paths.map((path) => readFileSync(path, 'utf8')); + } + + /* + * Convert verificationMode to rejectUnauthorized for more consistent config settings + * with the rest of Kibana + * @see https://github.com/elastic/kibana/blob/master/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts + */ + getAgentOptions(verificationMode: 'full' | 'certificate' | 'none') { + const agentOptions: AgentOptions = {}; + + switch (verificationMode) { + case 'none': + agentOptions.rejectUnauthorized = false; + break; + case 'certificate': + agentOptions.rejectUnauthorized = true; + agentOptions.checkServerIdentity = () => undefined; + break; + case 'full': + default: + agentOptions.rejectUnauthorized = true; + break; + } + + return agentOptions; + } +} + +export const entSearchHttpAgent = new EnterpriseSearchHttpAgent(); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index 3223471e4fc1a3..6ebf46abd39d33 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { mockConfig, mockLogger } from '../__mocks__'; +import { mockConfig, mockLogger, mockHttpAgent } from '../__mocks__'; import { ENTERPRISE_SEARCH_KIBANA_COOKIE, @@ -476,6 +476,7 @@ const EnterpriseSearchAPI = { headers: { Authorization: 'Basic 123', ...JSON_HEADER }, method: 'GET', body: undefined, + agent: mockHttpAgent, ...expectedParams, }); }, diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index 2fc0a13f2ff721..597f7524808e99 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -16,13 +16,15 @@ import { Logger, } from 'src/core/server'; +import { ConfigType } from '../'; + import { ENTERPRISE_SEARCH_KIBANA_COOKIE, JSON_HEADER, READ_ONLY_MODE_HEADER, } from '../../common/constants'; -import { ConfigType } from '../index'; +import { entSearchHttpAgent } from './enterprise_search_http_agent'; interface ConstructorDependencies { config: ConfigType; @@ -77,12 +79,15 @@ export class EnterpriseSearchRequestHandler { const url = encodeURI(this.enterpriseSearchUrl) + encodedPath + queryString; // Set up API options - const { method } = request.route; - const headers = { Authorization: request.headers.authorization as string, ...JSON_HEADER }; - const body = this.getBodyAsString(request.body as object | Buffer); + const options = { + method: request.route.method as string, + headers: { Authorization: request.headers.authorization as string, ...JSON_HEADER }, + body: this.getBodyAsString(request.body as object | Buffer), + agent: entSearchHttpAgent.getHttpAgent(), + }; // Call the Enterprise Search API - const apiResponse = await fetch(url, { method, headers, body }); + const apiResponse = await fetch(url, options); // Handle response headers this.setResponseHeaders(apiResponse); diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 1b9659899097dd..04bd304ee679f5 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -31,6 +31,7 @@ import { registerTelemetryUsageCollector as registerESTelemetryUsageCollector } import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; import { checkAccess } from './lib/check_access'; +import { entSearchHttpAgent } from './lib/enterprise_search_http_agent'; import { EnterpriseSearchRequestHandler, IEnterpriseSearchRequestHandler, @@ -81,6 +82,11 @@ export class EnterpriseSearchPlugin implements Plugin { const config = this.config; const log = this.logger; + /* + * Initialize config.ssl.certificateAuthorities file(s) - required for all API calls (+ access checks) + */ + entSearchHttpAgent.initializeHttpAgent(config); + /** * Register space/feature control */ From 9618fd7dfedf7c1b8ee869e8eb7f00fd4c2875bf Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Thu, 3 Jun 2021 11:00:12 -0400 Subject: [PATCH 41/77] [App Search] Added a persistent query tester flyout (#101071) --- .../results/add_result_flyout.test.tsx | 4 +- .../curation/results/add_result_flyout.tsx | 9 +- .../curation/results/add_result_logic.test.ts | 80 +------------ .../curation/results/add_result_logic.ts | 50 -------- .../layout/kibana_header_actions.test.tsx | 6 +- .../layout/kibana_header_actions.tsx | 10 +- .../components/query_tester/i18n.ts | 15 +++ .../components/query_tester/index.ts | 9 ++ .../query_tester/query_tester.test.tsx | 66 +++++++++++ .../components/query_tester/query_tester.tsx | 66 +++++++++++ .../query_tester/query_tester_button.test.tsx | 35 ++++++ .../query_tester/query_tester_button.tsx | 30 +++++ .../query_tester/query_tester_flyout.test.tsx | 25 ++++ .../query_tester/query_tester_flyout.tsx | 32 ++++++ .../app_search/components/search/index.ts | 8 ++ .../components/search/search_logic.test.ts | 108 ++++++++++++++++++ .../components/search/search_logic.ts | 73 ++++++++++++ .../routes/app_search/curations.test.ts | 35 ------ .../server/routes/app_search/index.ts | 2 + .../server/routes/app_search/search.test.ts | 35 ++++++ .../server/routes/app_search/search.ts | 39 +++++++ 21 files changed, 558 insertions(+), 179 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/search.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx index e12267d0eb1367..a0f178aca32b29 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.test.tsx @@ -17,7 +17,7 @@ import { CurationResult, AddResultFlyout } from './'; describe('AddResultFlyout', () => { const values = { - dataLoading: false, + searchDataLoading: false, searchQuery: '', searchResults: [], promotedIds: [], @@ -48,7 +48,7 @@ describe('AddResultFlyout', () => { describe('search input', () => { it('renders isLoading state correctly', () => { - setMockValues({ ...values, dataLoading: true }); + setMockValues({ ...values, searchDataLoading: true }); const wrapper = shallow(); expect(wrapper.find(EuiFieldSearch).prop('isLoading')).toEqual(true); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx index 6363919e32cc95..a20e4e137f899c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_flyout.tsx @@ -24,6 +24,7 @@ import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../../../shared/flash_messages'; +import { SearchLogic } from '../../../search'; import { RESULT_ACTIONS_DIRECTIONS, PROMOTE_DOCUMENT_ACTION, @@ -36,8 +37,10 @@ import { CurationLogic } from '../curation_logic'; import { AddResultLogic, CurationResult } from './'; export const AddResultFlyout: React.FC = () => { - const { searchQuery, searchResults, dataLoading } = useValues(AddResultLogic); - const { search, closeFlyout } = useActions(AddResultLogic); + const searchLogic = SearchLogic({ id: 'add-results-flyout' }); + const { searchQuery, searchResults, searchDataLoading } = useValues(searchLogic); + const { closeFlyout } = useActions(AddResultLogic); + const { search } = useActions(searchLogic); const { promotedIds, hiddenIds } = useValues(CurationLogic); const { addPromotedId, removePromotedId, addHiddenId, removeHiddenId } = useActions( @@ -63,7 +66,7 @@ export const AddResultFlyout: React.FC = () => { search(e.target.value)} - isLoading={dataLoading} + isLoading={searchDataLoading} placeholder={i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.curations.addResult.searchPlaceholder', { defaultMessage: 'Search engine documents' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts index a722ab96fc5741..e7007cdc093cb4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.test.ts @@ -5,31 +5,16 @@ * 2.0. */ -import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../../../__mocks__'; +import { LogicMounter } from '../../../../../__mocks__'; import '../../../../__mocks__/engine_logic.mock'; -import { nextTick } from '@kbn/test/jest'; - import { AddResultLogic } from './'; describe('AddResultLogic', () => { const { mount } = new LogicMounter(AddResultLogic); - const { http } = mockHttpValues; - const { flashAPIErrors } = mockFlashMessageHelpers; - - const MOCK_SEARCH_RESPONSE = { - results: [ - { id: { raw: 'document-1' }, _meta: { id: 'document-1', engine: 'some-engine' } }, - { id: { raw: 'document-2' }, _meta: { id: 'document-2', engine: 'some-engine' } }, - { id: { raw: 'document-3' }, _meta: { id: 'document-3', engine: 'some-engine' } }, - ], - }; const DEFAULT_VALUES = { isFlyoutOpen: false, - dataLoading: false, - searchQuery: '', - searchResults: [], }; beforeEach(() => { @@ -51,7 +36,6 @@ describe('AddResultLogic', () => { expect(AddResultLogic.values).toEqual({ ...DEFAULT_VALUES, isFlyoutOpen: true, - searchQuery: '', }); }); }); @@ -68,67 +52,5 @@ describe('AddResultLogic', () => { }); }); }); - - describe('search', () => { - it('sets searchQuery & dataLoading to true', () => { - mount({ searchQuery: '', dataLoading: false }); - - AddResultLogic.actions.search('hello world'); - - expect(AddResultLogic.values).toEqual({ - ...DEFAULT_VALUES, - searchQuery: 'hello world', - dataLoading: true, - }); - }); - }); - - describe('onSearch', () => { - it('sets searchResults & dataLoading to false', () => { - mount({ searchResults: [], dataLoading: true }); - - AddResultLogic.actions.onSearch(MOCK_SEARCH_RESPONSE); - - expect(AddResultLogic.values).toEqual({ - ...DEFAULT_VALUES, - searchResults: MOCK_SEARCH_RESPONSE.results, - dataLoading: false, - }); - }); - }); - }); - - describe('listeners', () => { - describe('search', () => { - beforeAll(() => jest.useFakeTimers()); - afterAll(() => jest.useRealTimers()); - - it('should make a GET API call with a search query', async () => { - http.get.mockReturnValueOnce(Promise.resolve(MOCK_SEARCH_RESPONSE)); - mount(); - jest.spyOn(AddResultLogic.actions, 'onSearch'); - - AddResultLogic.actions.search('hello world'); - jest.runAllTimers(); - await nextTick(); - - expect(http.get).toHaveBeenCalledWith( - '/api/app_search/engines/some-engine/curation_search', - { query: { query: 'hello world' } } - ); - expect(AddResultLogic.actions.onSearch).toHaveBeenCalledWith(MOCK_SEARCH_RESPONSE); - }); - - it('handles errors', async () => { - http.get.mockReturnValueOnce(Promise.reject('error')); - mount(); - - AddResultLogic.actions.search('test'); - jest.runAllTimers(); - await nextTick(); - - expect(flashAPIErrors).toHaveBeenCalledWith('error'); - }); - }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts index 808f4c86971eec..bcf18aa9625d7d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curation/results/add_result_logic.ts @@ -7,24 +7,13 @@ import { kea, MakeLogicType } from 'kea'; -import { flashAPIErrors } from '../../../../../shared/flash_messages'; -import { HttpLogic } from '../../../../../shared/http'; - -import { EngineLogic } from '../../../engine'; -import { Result } from '../../../result/types'; - interface AddResultValues { isFlyoutOpen: boolean; - dataLoading: boolean; - searchQuery: string; - searchResults: Result[]; } interface AddResultActions { openFlyout(): void; closeFlyout(): void; - search(query: string): { query: string }; - onSearch({ results }: { results: Result[] }): { results: Result[] }; } export const AddResultLogic = kea>({ @@ -32,8 +21,6 @@ export const AddResultLogic = kea ({ openFlyout: true, closeFlyout: true, - search: (query) => ({ query }), - onSearch: ({ results }) => ({ results }), }), reducers: () => ({ isFlyoutOpen: [ @@ -43,42 +30,5 @@ export const AddResultLogic = kea false, }, ], - dataLoading: [ - false, - { - search: () => true, - onSearch: () => false, - }, - ], - searchQuery: [ - '', - { - search: (_, { query }) => query, - openFlyout: () => '', - }, - ], - searchResults: [ - [], - { - onSearch: (_, { results }) => results, - }, - ], - }), - listeners: ({ actions }) => ({ - search: async ({ query }, breakpoint) => { - await breakpoint(250); - - const { http } = HttpLogic.values; - const { engineName } = EngineLogic.values; - - try { - const response = await http.get(`/api/app_search/engines/${engineName}/curation_search`, { - query: { query }, - }); - actions.onSearch(response); - } catch (e) { - flashAPIErrors(e); - } - }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx index 21fc2b235d83cf..096d858cd11918 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiButtonEmpty } from '@elastic/eui'; +import { QueryTesterButton } from '../query_tester'; import { KibanaHeaderActions } from './kibana_header_actions'; @@ -27,7 +27,7 @@ describe('KibanaHeaderActions', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiButtonEmpty).exists()).toBe(true); + expect(wrapper.find(QueryTesterButton).exists()).toBe(true); }); it('does not render a "Query Tester" button if there is no engine available', () => { @@ -35,6 +35,6 @@ describe('KibanaHeaderActions', () => { engineName: '', }); const wrapper = shallow(); - expect(wrapper.find(EuiButtonEmpty).exists()).toBe(false); + expect(wrapper.find(QueryTesterButton).exists()).toBe(false); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx index b2e810962df029..e23c8ff8f0f0c0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx @@ -9,10 +9,10 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EngineLogic } from '../engine'; +import { QueryTesterButton } from '../query_tester'; export const KibanaHeaderActions: React.FC = () => { const { engineName } = useValues(EngineLogic); @@ -21,11 +21,7 @@ export const KibanaHeaderActions: React.FC = () => { {engineName && ( - - {i18n.translate('xpack.enterpriseSearch.appSearch.engine.queryTesterButtonLabel', { - defaultMessage: 'Query tester', - })} - + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts new file mode 100644 index 00000000000000..a1b1f6769beafc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/i18n.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const QUERY_TESTER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.queryTesterTitle', + { + defaultMessage: 'Query tester', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts new file mode 100644 index 00000000000000..b2b8ad0dd12557 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { QueryTesterFlyout } from './query_tester_flyout'; +export { QueryTesterButton } from './query_tester_button'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx new file mode 100644 index 00000000000000..160be70cbbfc9c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues, setMockActions } from '../../../__mocks__/kea.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiFieldSearch } from '@elastic/eui'; + +import { SchemaType } from '../../../shared/schema/types'; +import { Result } from '../result'; + +import { QueryTester } from './query_tester'; + +describe('QueryTester', () => { + const values = { + searchQuery: 'foo', + searchResults: [{ id: { raw: '1' } }, { id: { raw: '2' } }, { id: { raw: '3' } }], + searchDataLoading: false, + engine: { + schema: { + foo: SchemaType.Text, + }, + }, + }; + + const actions = { + search: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders with a search box and results', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFieldSearch).prop('value')).toBe('foo'); + expect(wrapper.find(EuiFieldSearch).prop('isLoading')).toBe(false); + expect(wrapper.find(Result)).toHaveLength(3); + }); + + it('will update the search term in state when the user updates the search box', () => { + const wrapper = shallow(); + wrapper.find(EuiFieldSearch).simulate('change', { target: { value: 'bar' } }); + expect(actions.search).toHaveBeenCalledWith('bar'); + }); + + it('will render an empty prompt when there are no results', () => { + setMockValues({ + ...values, + searchResults: [], + }); + const wrapper = shallow(); + wrapper.find(EuiFieldSearch).simulate('change', { target: { value: 'bar' } }); + expect(wrapper.find(Result)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx new file mode 100644 index 00000000000000..374b6bd1a77b37 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiEmptyPrompt, EuiFieldSearch, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { EngineLogic } from '../engine'; +import { Result } from '../result'; +import { SearchLogic } from '../search'; + +export const QueryTester: React.FC = () => { + const logic = SearchLogic({ id: 'query-tester' }); + const { searchQuery, searchResults, searchDataLoading } = useValues(logic); + const { search } = useActions(logic); + const { engine } = useValues(EngineLogic); + + return ( + <> + search(e.target.value)} + isLoading={searchDataLoading} + placeholder={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.queryTester.searchPlaceholder', + { defaultMessage: 'Search engine documents' } + )} + fullWidth + autoFocus + /> + + {searchResults.length > 0 ? ( + searchResults.map((result) => { + const id = result.id.raw; + + return ( + + + + + ); + }) + ) : ( + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx new file mode 100644 index 00000000000000..4d2c3286ff516c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButtonEmpty } from '@elastic/eui'; + +import { QueryTesterFlyout, QueryTesterButton } from '.'; + +describe('QueryTesterButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButtonEmpty).exists()).toBe(true); + expect(wrapper.find(QueryTesterFlyout).exists()).toBe(false); + }); + + it('will render a QueryTesterFlyout when pressed and close on QueryTesterFlyout close', () => { + const wrapper = shallow(); + wrapper.find(EuiButtonEmpty).simulate('click'); + expect(wrapper.find(QueryTesterFlyout).exists()).toBe(true); + + wrapper.find(QueryTesterFlyout).simulate('close'); + expect(wrapper.find(QueryTesterFlyout).exists()).toBe(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx new file mode 100644 index 00000000000000..89381914b6db67 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_button.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { EuiButtonEmpty } from '@elastic/eui'; + +import { QUERY_TESTER_TITLE } from './i18n'; + +import { QueryTesterFlyout } from '.'; + +export const QueryTesterButton: React.FC = () => { + const [isQueryTesterOpen, setIsQueryTesterOpen] = useState(false); + return ( + <> + setIsQueryTesterOpen(!isQueryTesterOpen)} + > + {QUERY_TESTER_TITLE} + + {isQueryTesterOpen && setIsQueryTesterOpen(false)} />} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx new file mode 100644 index 00000000000000..8c25589f04639d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.test.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlyout } from '@elastic/eui'; + +import { QueryTester } from './query_tester'; +import { QueryTesterFlyout } from './query_tester_flyout'; + +describe('QueryTesterFlyout', () => { + const onClose = jest.fn(); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(QueryTester).exists()).toBe(true); + expect(wrapper.find(EuiFlyout).prop('onClose')).toEqual(onClose); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx new file mode 100644 index 00000000000000..d419bef472de31 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/query_tester/query_tester_flyout.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; + +import { QUERY_TESTER_TITLE } from './i18n'; +import { QueryTester } from './query_tester'; + +interface Props { + onClose: () => void; +} + +export const QueryTesterFlyout: React.FC = ({ onClose }) => { + return ( + + + +

{QUERY_TESTER_TITLE}

+
+
+ + + +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts new file mode 100644 index 00000000000000..68cad7b0a0c77a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SearchLogic } from './search_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts new file mode 100644 index 00000000000000..784ebd0aad0cba --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.test.ts @@ -0,0 +1,108 @@ +/* + * 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 '../../__mocks__/engine_logic.mock'; + +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; + +import { SearchLogic } from './search_logic'; + +describe('SearchLogic', () => { + const { mount } = new LogicMounter(SearchLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + const MOCK_SEARCH_RESPONSE = { + results: [ + { id: { raw: 'document-1' }, _meta: { id: 'document-1', engine: 'some-engine' } }, + { id: { raw: 'document-2' }, _meta: { id: 'document-2', engine: 'some-engine' } }, + { id: { raw: 'document-3' }, _meta: { id: 'document-3', engine: 'some-engine' } }, + ], + }; + + const DEFAULT_VALUES = { + searchDataLoading: false, + searchQuery: '', + searchResults: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mountLogic = (values: object = {}) => mount(values, { id: '1' }); + + it('has expected default values', () => { + const logic = mountLogic(); + expect(logic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('search', () => { + it('sets searchQuery & searchDataLoading to true', () => { + const logic = mountLogic({ searchQuery: '', searchDataLoading: false }); + + logic.actions.search('hello world'); + + expect(logic.values).toEqual({ + ...DEFAULT_VALUES, + searchQuery: 'hello world', + searchDataLoading: true, + }); + }); + }); + + describe('onSearch', () => { + it('sets searchResults & searchDataLoading to false', () => { + const logic = mountLogic({ searchResults: [], searchDataLoading: true }); + + logic.actions.onSearch(MOCK_SEARCH_RESPONSE); + + expect(logic.values).toEqual({ + ...DEFAULT_VALUES, + searchResults: MOCK_SEARCH_RESPONSE.results, + searchDataLoading: false, + }); + }); + }); + }); + + describe('listeners', () => { + describe('search', () => { + beforeAll(() => jest.useFakeTimers()); + afterAll(() => jest.useRealTimers()); + + it('should make a GET API call with a search query', async () => { + http.get.mockReturnValueOnce(Promise.resolve(MOCK_SEARCH_RESPONSE)); + const logic = mountLogic(); + jest.spyOn(logic.actions, 'onSearch'); + + logic.actions.search('hello world'); + jest.runAllTimers(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/search', { + query: { query: 'hello world' }, + }); + expect(logic.actions.onSearch).toHaveBeenCalledWith(MOCK_SEARCH_RESPONSE); + }); + + it('handles errors', async () => { + http.get.mockReturnValueOnce(Promise.reject('error')); + const logic = mountLogic(); + + logic.actions.search('test'); + jest.runAllTimers(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts new file mode 100644 index 00000000000000..d9b7d575ae0e1d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search/search_logic.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; + +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; + +import { Result } from '../result/types'; + +interface SearchValues { + searchDataLoading: boolean; + searchQuery: string; + searchResults: Result[]; +} + +interface SearchActions { + search(query: string): { query: string }; + onSearch({ results }: { results: Result[] }): { results: Result[] }; +} + +export const SearchLogic = kea>({ + key: (props) => props.id, + path: (key: string) => ['enterprise_search', 'app_search', 'search_logic', key], + actions: () => ({ + search: (query) => ({ query }), + onSearch: ({ results }) => ({ results }), + }), + reducers: () => ({ + searchDataLoading: [ + false, + { + search: () => true, + onSearch: () => false, + }, + ], + searchQuery: [ + '', + { + search: (_, { query }) => query, + }, + ], + searchResults: [ + [], + { + onSearch: (_, { results }) => results, + }, + ], + }), + listeners: ({ actions }) => ({ + search: async ({ query }, breakpoint) => { + await breakpoint(250); + + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/search`, { + query: { query }, + }); + actions.onSearch(response); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts index 045d3d12e8bcf3..08e123a98cd314 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts @@ -229,39 +229,4 @@ describe('curations routes', () => { }); }); }); - - describe('GET /api/app_search/engines/{engineName}/curation_search', () => { - let mockRouter: MockRouter; - - beforeEach(() => { - jest.clearAllMocks(); - mockRouter = new MockRouter({ - method: 'get', - path: '/api/app_search/engines/{engineName}/curation_search', - }); - - registerCurationsRoutes({ - ...mockDependencies, - router: mockRouter.router, - }); - }); - - it('creates a request handler', () => { - expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ - path: '/api/as/v1/engines/:engineName/search.json', - }); - }); - - describe('validates', () => { - it('required query param', () => { - const request = { query: { query: 'some query' } }; - mockRouter.shouldValidate(request); - }); - - it('missing query', () => { - const request = { query: {} }; - mockRouter.shouldThrow(request); - }); - }); - }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 18de4580318a27..6ccdce0935d935 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -17,6 +17,7 @@ import { registerOnboardingRoutes } from './onboarding'; import { registerResultSettingsRoutes } from './result_settings'; import { registerRoleMappingsRoutes } from './role_mappings'; import { registerSchemaRoutes } from './schema'; +import { registerSearchRoutes } from './search'; import { registerSearchSettingsRoutes } from './search_settings'; import { registerSearchUIRoutes } from './search_ui'; import { registerSettingsRoutes } from './settings'; @@ -31,6 +32,7 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerDocumentsRoutes(dependencies); registerDocumentRoutes(dependencies); registerSchemaRoutes(dependencies); + registerSearchRoutes(dependencies); registerSourceEnginesRoutes(dependencies); registerCurationsRoutes(dependencies); registerSynonymsRoutes(dependencies); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts new file mode 100644 index 00000000000000..9262dd9e574ada --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerSearchRoutes } from './search'; + +describe('search routes', () => { + describe('GET /api/app_search/engines/{engineName}/search', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/schema', + }); + + registerSearchRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v1/engines/:engineName/search.json', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search.ts new file mode 100644 index 00000000000000..016f71e7e65b8c --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerSearchRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/search', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + query: schema.object({ + query: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v1/engines/:engineName/search.json', + }) + ); +} From c260407640865a60035102b6a913de016b8f1dec Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 3 Jun 2021 17:16:42 +0200 Subject: [PATCH 42/77] added screenshot_mode to app services ownership (#101257) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b071e06f1bc54e..9ccf660946dd56 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -54,6 +54,7 @@ /src/plugins/share/ @elastic/kibana-app-services /src/plugins/ui_actions/ @elastic/kibana-app-services /src/plugins/index_pattern_field_editor @elastic/kibana-app-services +/src/plugins/screenshot_mode @elastic/kibana-app-services /x-pack/examples/ui_actions_enhanced_examples/ @elastic/kibana-app-services /x-pack/plugins/data_enhanced/ @elastic/kibana-app-services /x-pack/plugins/embeddable_enhanced/ @elastic/kibana-app-services From a9a90131208604af79e5476ecc10e73433af934a Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 3 Jun 2021 18:17:14 +0300 Subject: [PATCH 43/77] [Pie] New implementation of the vislib pie chart with es-charts (#83929) * es lint fix * Add formatter on the buckets labels * Config the new plugin, toggle tooltip * Aff filtering on slice click * minor fixes * fix eslint error * use legacy palette for now * Add color picker to legend colors * Fix ts error * Add legend actions * Fix bug on Color Picker and remove local state as it is unecessary * Fix some bugs on colorPicker * Add setting for the user to select between the legacy palette or the eui ones * small enhancements, treat empty labels with (empty) * Fix color picker bugs with multiple layers * fixes on internationalization * Create migration script for pie chart and legacy palette * Add unit tests (wip) and a small refactoring * Add unit tests and move some things to utils, useMemo and useCallback where it should * Add jest config file * Fix jest test * fix api integration failure * Fix to_ast_esaggs for new pie plugin * Close legendColorPicker popover when user clicks outside * Fix warning * Remove getter/setters and refactor * Remove kibanaUtils from pie plugin as it is not needed * Add new values to the migration script * Fix bug on not changing color for expty string * remove from migration script as they don't need it * Fix editor settings for old and new implementation * fix uistate type * Disable split chart for the new plugin for now * Remove temp folder * Move translations to the pie plugin * Fix CI failures * Add unit test for the editor config * Types cleanup * Fix types vol2 * Minor improvements * Display data on the inspector * Cleanup translations * Add telemetry for new editor pie options * Fix missing translation * Use Eui component to detect click outside the color picker popover * Retrieve color picker from editor and syncColors on dashboard * Lazy load palette service * Add the new plugin to ts references, fix tests, refactor * Fix ci failure * Move charts library switch to vislib plugin * Remove cyclic dependencies * Modify license headers * Move charts library switch to visualizations plugin * Fix i18n on the switch moved to visualizations plugin * Update license * Fix tests * Fix bugs created by new charts version * Fix the i18n switch problem * Update the migration script * Identify if colorIsOverwritten or not * Small multiples, missing the click event * Fixes the UX for small multiples part1 * Distinct colors per slice implementation * Fix ts references problem * Fix some small multiples bugs * Add unit tests * Fix ts ref problem * Fix TS problems caused by es-charts new version * Update the sample pie visualizations with the new eui palette * Allows filtering by the small multiples value * Apply sortPredicate on partition layers * Fix vilib test * Enable functional tests for new plugin * Fix some functional tests * Minor fix * Fix functional tests * Fix dashboard tests * Fix all dashboard tests * Apply some improvements * Explicit params instead of visConfig Json * Fix i18n failure * Add top level setting * Minor fix * Fix jest tests * Address PR comments * Fix i18n error * fix functional test * Add an icon tip on the distinct colors per slice switch * Fix some of the PR comments * Address more PR comments * Small fix * Functional test * address some PR comments * Add padding to the pie container * Add a max width to the container * Improve dashboard functional test * Move the labels expression function to the pie plugin * Fix i18n * Fix functional test * Apply PR comments * Do not forget to also add the migration to them embeddable too :D * Fix distinct colors for IP range layer * Remove console errors * Fix small mulitples colors with multiple layers * Fix lint problem * Fix problems created from merging with master * Address PR comments * Change the config in order the pie chart to not appear so huge on the editor * Address PR comments * Change the max percentage digits to 4 * Change the max size to 1000 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 1 + .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + src/plugins/charts/public/index.ts | 1 + .../public/static/components/color_picker.tsx | 31 +- .../data_sets/ecommerce/saved_objects.ts | 2 +- .../data_sets/flights/saved_objects.ts | 2 +- .../data_sets/logs/saved_objects.ts | 2 +- src/plugins/vis_type_pie/README.md | 1 + .../server => vis_type_pie/common}/index.ts | 4 +- src/plugins/vis_type_pie/jest.config.js | 13 + src/plugins/vis_type_pie/kibana.json | 8 + .../public/__snapshots__/pie_fn.test.ts.snap | 73 + .../public/__snapshots__/to_ast.test.ts.snap | 122 ++ src/plugins/vis_type_pie/public/chart.scss | 18 + .../public/components/chart_split.tsx | 67 + .../vis_type_pie/public/editor/collections.ts | 40 + .../public/editor/components/index.tsx | 26 + .../public/editor/components/pie.test.tsx | 124 ++ .../public/editor/components/pie.tsx | 287 ++++ .../components/truncate_labels.test.tsx | 51 + .../editor/components/truncate_labels.tsx | 43 + .../vis_type_pie/public/editor/positions.ts | 37 + .../public/expression_functions/pie_labels.ts | 113 ++ src/plugins/vis_type_pie/public/index.ts | 14 + src/plugins/vis_type_pie/public/mocks.ts | 328 ++++ .../public/pie_component.test.tsx | 123 ++ .../vis_type_pie/public/pie_component.tsx | 355 +++++ .../vis_type_pie/public/pie_fn.test.ts | 53 + src/plugins/vis_type_pie/public/pie_fn.ts | 153 ++ .../vis_type_pie/public/pie_renderer.tsx | 63 + src/plugins/vis_type_pie/public/plugin.ts | 73 + .../public/sample_vis.test.mocks.ts | 1332 +++++++++++++++++ .../vis_type_pie/public/to_ast.test.ts | 31 + src/plugins/vis_type_pie/public/to_ast.ts | 71 + .../vis_type_pie/public/to_ast_esaggs.ts | 33 + .../vis_type_pie/public/types/index.ts | 9 + .../vis_type_pie/public/types/types.ts | 96 ++ .../public/utils/filter_helpers.test.ts | 98 ++ .../public/utils/filter_helpers.ts | 89 ++ .../public/utils/get_color_picker.test.tsx | 116 ++ .../public/utils/get_color_picker.tsx | 121 ++ .../public/utils/get_columns.test.ts | 222 +++ .../vis_type_pie/public/utils/get_columns.ts | 43 + .../vis_type_pie/public/utils/get_config.ts | 76 + .../public/utils/get_distinct_series.test.ts | 30 + .../public/utils/get_distinct_series.ts | 31 + .../public/utils/get_layers.test.ts | 114 ++ .../vis_type_pie/public/utils/get_layers.ts | 186 +++ .../public/utils/get_legend_actions.tsx | 117 ++ .../utils/get_split_dimension_accessor.ts | 31 + .../vis_type_pie/public/utils/index.ts | 16 + .../vis_type_pie/public/vis_type/index.ts | 14 + .../vis_type_pie/public/vis_type/pie.ts | 98 ++ src/plugins/vis_type_pie/tsconfig.json | 24 + src/plugins/vis_type_vislib/kibana.json | 2 +- .../public/editor/components/index.tsx | 6 - .../public/editor/components/pie.tsx | 97 -- src/plugins/vis_type_vislib/public/pie.ts | 75 +- src/plugins/vis_type_vislib/public/plugin.ts | 5 +- .../vis_type_vislib/public/to_ast_pie.test.ts | 2 +- .../build_hierarchical_data.test.ts | 4 +- .../hierarchical/build_hierarchical_data.ts | 17 +- src/plugins/vis_type_vislib/tsconfig.json | 1 + src/plugins/vis_type_xy/common/index.ts | 2 - src/plugins/vis_type_xy/kibana.json | 1 - src/plugins/vis_type_xy/public/plugin.ts | 2 +- .../public/sample_vis.test.mocks.ts | 1319 ---------------- src/plugins/vis_type_xy/server/plugin.ts | 46 - .../visualizations/common/constants.ts | 1 + src/plugins/visualizations/kibana.json | 3 +- .../visualize_embeddable_factory.ts | 10 +- .../visualization_common_migrations.ts | 23 + ...ualization_saved_object_migrations.test.ts | 48 + .../visualization_saved_object_migrations.ts | 26 +- src/plugins/visualizations/server/plugin.ts | 23 +- test/examples/embeddables/dashboard.ts | 6 +- .../apps/dashboard/dashboard_state.ts | 16 +- test/functional/apps/visualize/_pie_chart.ts | 31 +- test/functional/apps/visualize/index.ts | 1 + .../page_objects/visualize_chart_page.ts | 122 +- .../page_objects/visualize_editor_page.ts | 8 + .../services/visualizations/pie_chart.ts | 91 +- tsconfig.json | 1 + tsconfig.refs.json | 1 + .../translations/translations/ja-JP.json | 26 +- .../translations/translations/zh-CN.json | 26 +- .../dashboard_to_dashboard_drilldown.ts | 6 +- 89 files changed, 5602 insertions(+), 1678 deletions(-) create mode 100644 src/plugins/vis_type_pie/README.md rename src/plugins/{vis_type_xy/server => vis_type_pie/common}/index.ts (76%) create mode 100644 src/plugins/vis_type_pie/jest.config.js create mode 100644 src/plugins/vis_type_pie/kibana.json create mode 100644 src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap create mode 100644 src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap create mode 100644 src/plugins/vis_type_pie/public/chart.scss create mode 100644 src/plugins/vis_type_pie/public/components/chart_split.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/collections.ts create mode 100644 src/plugins/vis_type_pie/public/editor/components/index.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/pie.test.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/pie.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx create mode 100644 src/plugins/vis_type_pie/public/editor/positions.ts create mode 100644 src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts create mode 100644 src/plugins/vis_type_pie/public/index.ts create mode 100644 src/plugins/vis_type_pie/public/mocks.ts create mode 100644 src/plugins/vis_type_pie/public/pie_component.test.tsx create mode 100644 src/plugins/vis_type_pie/public/pie_component.tsx create mode 100644 src/plugins/vis_type_pie/public/pie_fn.test.ts create mode 100644 src/plugins/vis_type_pie/public/pie_fn.ts create mode 100644 src/plugins/vis_type_pie/public/pie_renderer.tsx create mode 100644 src/plugins/vis_type_pie/public/plugin.ts create mode 100644 src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts create mode 100644 src/plugins/vis_type_pie/public/to_ast.test.ts create mode 100644 src/plugins/vis_type_pie/public/to_ast.ts create mode 100644 src/plugins/vis_type_pie/public/to_ast_esaggs.ts create mode 100644 src/plugins/vis_type_pie/public/types/index.ts create mode 100644 src/plugins/vis_type_pie/public/types/types.ts create mode 100644 src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/filter_helpers.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx create mode 100644 src/plugins/vis_type_pie/public/utils/get_color_picker.tsx create mode 100644 src/plugins/vis_type_pie/public/utils/get_columns.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_columns.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_config.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_distinct_series.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_layers.test.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_layers.ts create mode 100644 src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx create mode 100644 src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts create mode 100644 src/plugins/vis_type_pie/public/utils/index.ts create mode 100644 src/plugins/vis_type_pie/public/vis_type/index.ts create mode 100644 src/plugins/vis_type_pie/public/vis_type/pie.ts create mode 100644 src/plugins/vis_type_pie/tsconfig.json delete mode 100644 src/plugins/vis_type_vislib/public/editor/components/pie.tsx delete mode 100644 src/plugins/vis_type_xy/server/plugin.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9ccf660946dd56..68fadd4958cbab 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -24,6 +24,7 @@ /src/plugins/vis_type_vega/ @elastic/kibana-app /src/plugins/vis_type_vislib/ @elastic/kibana-app /src/plugins/vis_type_xy/ @elastic/kibana-app +/src/plugins/vis_type_pie/ @elastic/kibana-app /src/plugins/visualize/ @elastic/kibana-app /src/plugins/visualizations/ @elastic/kibana-app /packages/kbn-tinymath/ @elastic/kibana-app diff --git a/.i18nrc.json b/.i18nrc.json index 57dffa4147e525..ad91042a2172de 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -56,6 +56,7 @@ "visTypeVega": "src/plugins/vis_type_vega", "visTypeVislib": "src/plugins/vis_type_vislib", "visTypeXy": "src/plugins/vis_type_xy", + "visTypePie": "src/plugins/vis_type_pie", "visualizations": "src/plugins/visualizations", "visualize": "src/plugins/visualize", "apmOss": "src/plugins/apm_oss", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 087626240ff337..7d06562547f70c 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -265,6 +265,10 @@ The plugin exposes the static DefaultEditorController class to consume. |Contains the metric visualization. +|{kib-repo}blob/{branch}/src/plugins/vis_type_pie/README.md[visTypePie] +|Contains the pie chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting. + + |{kib-repo}blob/{branch}/src/plugins/vis_type_table/README.md[visTypeTable] |Contains the data table visualization, that allows presenting data in a simple table format. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 6ccf6269751b1e..3427eee4b5c0ba 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -87,6 +87,7 @@ pageLoadAssetSize: visDefaultEditor: 50178 visTypeMarkdown: 30896 visTypeMetric: 42790 + visTypePie: 34051 visTypeTable: 94934 visTypeTagcloud: 37575 visTypeTimelion: 68883 diff --git a/src/plugins/charts/public/index.ts b/src/plugins/charts/public/index.ts index b42407bb10365c..cc1a54c2e25b09 100644 --- a/src/plugins/charts/public/index.ts +++ b/src/plugins/charts/public/index.ts @@ -14,6 +14,7 @@ export { ChartsPluginSetup, ChartsPluginStart } from './plugin'; export * from './static'; export * from './services/palettes/types'; +export { lightenColor } from './services/palettes/lighten_color'; export { PaletteOutput, CustomPaletteArguments, diff --git a/src/plugins/charts/public/static/components/color_picker.tsx b/src/plugins/charts/public/static/components/color_picker.tsx index 4974400a3767a3..813748accd8fdb 100644 --- a/src/plugins/charts/public/static/components/color_picker.tsx +++ b/src/plugins/charts/public/static/components/color_picker.tsx @@ -18,7 +18,7 @@ import { EuiFlexGroup, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; - +import { lightenColor } from '../../services/palettes/lighten_color'; import './color_picker.scss'; export const legacyColors: string[] = [ @@ -105,6 +105,14 @@ interface ColorPickerProps { * Callback for onKeyPress event */ onKeyDown?: (e: React.KeyboardEvent) => void; + /** + * Optional define the series maxDepth + */ + maxDepth?: number; + /** + * Optional define the layer index + */ + layerIndex?: number; } const euiColors = euiPaletteColorBlind({ rotations: 4, order: 'group' }); @@ -115,6 +123,8 @@ export const ColorPicker = ({ useLegacyColors = true, colorIsOverwritten = true, onKeyDown, + maxDepth, + layerIndex, }: ColorPickerProps) => { const legendColors = useLegacyColors ? legacyColors : euiColors; @@ -159,13 +169,18 @@ export const ColorPicker = ({ ))}
- {legendColors.some((c) => c === selectedColor) && colorIsOverwritten && ( - - onChange(null, e)}> - - - - )} + {legendColors.some( + (c) => + c === selectedColor || + (layerIndex && maxDepth && lightenColor(c, layerIndex, maxDepth) === selectedColor) + ) && + colorIsOverwritten && ( + + onChange(null, e)}> + + + + )}
); }; diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts index dc5831aa00a0bc..a12a2ff195211d 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts @@ -45,7 +45,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[eCommerce] Sales by Gender', }), visState: - '{"title":"[eCommerce] Sales by Gender","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"customer_gender","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', + '{"title":"[eCommerce] Sales by Gender","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100},"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"customer_gender","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', uiStateJSON: '{}', description: '', version: 1, diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts index 1fa19189b8c848..05a3d012d707c1 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts @@ -100,7 +100,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Flights] Airline Carrier', }), visState: - '{"title":"[Flights] Airline Carrier","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"Carrier","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', + '{"title":"[Flights] Airline Carrier","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100},"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"Carrier","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', uiStateJSON: '{"vis":{"legendOpen":false}}', description: '', version: 1, diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts index 4a17f96bf89bac..661e6ca0ce50f3 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts @@ -234,7 +234,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Logs] Visitors by OS', }), visState: - '{"title":"[Logs] Visitors by OS","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"machine.os.keyword","otherBucket":true,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing","size":10,"order":"desc","orderBy":"1"}}]}', + '{"title":"[Logs] Visitors by OS","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100},"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"machine.os.keyword","otherBucket":true,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing","size":10,"order":"desc","orderBy":"1"}}]}', uiStateJSON: '{}', description: '', version: 1, diff --git a/src/plugins/vis_type_pie/README.md b/src/plugins/vis_type_pie/README.md new file mode 100644 index 00000000000000..41b8131a5381d0 --- /dev/null +++ b/src/plugins/vis_type_pie/README.md @@ -0,0 +1 @@ +Contains the pie chart implementation using the elastic-charts library. The goal is to eventually deprecate the old implementation and keep only this. Until then, the library used is defined by the Legacy charts library advanced setting. \ No newline at end of file diff --git a/src/plugins/vis_type_xy/server/index.ts b/src/plugins/vis_type_pie/common/index.ts similarity index 76% rename from src/plugins/vis_type_xy/server/index.ts rename to src/plugins/vis_type_pie/common/index.ts index bfd8b7d28a98dd..1aa1680530b324 100644 --- a/src/plugins/vis_type_xy/server/index.ts +++ b/src/plugins/vis_type_pie/common/index.ts @@ -6,6 +6,4 @@ * Side Public License, v 1. */ -import { VisTypeXyServerPlugin } from './plugin'; - -export const plugin = () => new VisTypeXyServerPlugin(); +export const DEFAULT_PERCENT_DECIMALS = 2; diff --git a/src/plugins/vis_type_pie/jest.config.js b/src/plugins/vis_type_pie/jest.config.js new file mode 100644 index 00000000000000..e4900ef4a35c8f --- /dev/null +++ b/src/plugins/vis_type_pie/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/vis_type_pie'], +}; diff --git a/src/plugins/vis_type_pie/kibana.json b/src/plugins/vis_type_pie/kibana.json new file mode 100644 index 00000000000000..c2d51fba8260dd --- /dev/null +++ b/src/plugins/vis_type_pie/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "visTypePie", + "version": "kibana", + "ui": true, + "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection"], + "requiredBundles": ["visDefaultEditor"] + } + \ No newline at end of file diff --git a/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap b/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap new file mode 100644 index 00000000000000..dc83d9fdf48ac5 --- /dev/null +++ b/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`interpreter/functions#pie returns an object with the correct structure 1`] = ` +Object { + "as": "pie_vis", + "type": "render", + "value": Object { + "params": Object { + "listenOnChange": true, + }, + "syncColors": false, + "visConfig": Object { + "addLegend": true, + "addTooltip": true, + "buckets": undefined, + "dimensions": Object { + "buckets": undefined, + "metric": Object { + "accessor": 0, + "aggType": "count", + "format": Object { + "id": "number", + }, + "params": Object {}, + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "distinctColors": false, + "isDonut": true, + "labels": Object { + "percentDecimals": 2, + "position": "default", + "show": false, + "truncate": 100, + "values": true, + "valuesFormat": "percent", + }, + "legendPosition": "right", + "metric": Object { + "accessor": 0, + "aggType": "count", + "format": Object { + "id": "number", + }, + "params": Object {}, + }, + "nestedLegend": true, + "palette": Object { + "name": "kibana_palette", + "type": "palette", + }, + "splitColumn": undefined, + "splitRow": undefined, + }, + "visData": Object { + "columns": Array [ + Object { + "id": "col-0-1", + "name": "Count", + }, + ], + "rows": Array [ + Object { + "col-0-1": 0, + }, + ], + "type": "datatable", + }, + "visType": "pie", + }, +} +`; diff --git a/src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap new file mode 100644 index 00000000000000..0c8398a142027c --- /dev/null +++ b/src/plugins/vis_type_pie/public/__snapshots__/to_ast.test.ts.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`vis type pie vis toExpressionAst function should match basic snapshot 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "aggs": Array [], + "index": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "id": Array [ + "123", + ], + }, + "function": "indexPatternLoad", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "metricsAtAllLevels": Array [ + true, + ], + "partialRows": Array [ + false, + ], + }, + "function": "esaggs", + "type": "function", + }, + Object { + "arguments": Object { + "addLegend": Array [ + true, + ], + "addTooltip": Array [ + true, + ], + "buckets": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 1, + ], + "format": Array [ + "terms", + ], + "formatParams": Array [ + "{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "isDonut": Array [ + true, + ], + "labels": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "lastLevel": Array [ + true, + ], + "show": Array [ + true, + ], + "truncate": Array [ + 100, + ], + "values": Array [ + true, + ], + }, + "function": "pielabels", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "legendPosition": Array [ + "right", + ], + "metric": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + 0, + ], + "format": Array [ + "number", + ], + }, + "function": "visdimension", + "type": "function", + }, + ], + "type": "expression", + }, + ], + }, + "function": "pie_vis", + "type": "function", + }, + ], + "type": "expression", +} +`; diff --git a/src/plugins/vis_type_pie/public/chart.scss b/src/plugins/vis_type_pie/public/chart.scss new file mode 100644 index 00000000000000..8c098b13581f50 --- /dev/null +++ b/src/plugins/vis_type_pie/public/chart.scss @@ -0,0 +1,18 @@ +.pieChart__wrapper, +.pieChart__container { + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; +} + +.pieChart__container { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + padding: $euiSizeS; + margin-left: auto; + margin-right: auto; +} diff --git a/src/plugins/vis_type_pie/public/components/chart_split.tsx b/src/plugins/vis_type_pie/public/components/chart_split.tsx new file mode 100644 index 00000000000000..46f841113c03d4 --- /dev/null +++ b/src/plugins/vis_type_pie/public/components/chart_split.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { Accessor, AccessorFn, GroupBy, GroupBySort, SmallMultiples } from '@elastic/charts'; +import { DatatableColumn } from '../../../expressions/public'; +import { SplitDimensionParams } from '../types'; + +interface ChartSplitProps { + splitColumnAccessor?: Accessor | AccessorFn; + splitRowAccessor?: Accessor | AccessorFn; + splitDimension?: DatatableColumn; +} + +const CHART_SPLIT_ID = '__pie_chart_split__'; +export const SMALL_MULTIPLES_ID = '__pie_chart_sm__'; + +export const ChartSplit = ({ + splitColumnAccessor, + splitRowAccessor, + splitDimension, +}: ChartSplitProps) => { + if (!splitColumnAccessor && !splitRowAccessor) return null; + let sort: GroupBySort = 'alphaDesc'; + if (splitDimension?.meta?.params?.id === 'terms') { + const params = splitDimension?.meta?.sourceParams?.params as SplitDimensionParams; + sort = params?.order === 'asc' ? 'alphaAsc' : 'alphaDesc'; + } + + return ( + <> + { + const splitTypeAccessor = splitColumnAccessor || splitRowAccessor; + if (splitTypeAccessor) { + return typeof splitTypeAccessor === 'function' + ? splitTypeAccessor(datum) + : datum[splitTypeAccessor]; + } + return spec.id; + }} + sort={sort} + /> + + + ); +}; diff --git a/src/plugins/vis_type_pie/public/editor/collections.ts b/src/plugins/vis_type_pie/public/editor/collections.ts new file mode 100644 index 00000000000000..d65e933a8835c4 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/collections.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 { i18n } from '@kbn/i18n'; +import { LabelPositions, ValueFormats } from '../types'; + +export const getLabelPositions = [ + { + text: i18n.translate('visTypePie.labelPositions.insideText', { + defaultMessage: 'Inside', + }), + value: LabelPositions.INSIDE, + }, + { + text: i18n.translate('visTypePie.labelPositions.insideOrOutsideText', { + defaultMessage: 'Inside or outside', + }), + value: LabelPositions.DEFAULT, + }, +]; + +export const getValuesFormats = [ + { + text: i18n.translate('visTypePie.valuesFormats.percent', { + defaultMessage: 'Show percent', + }), + value: ValueFormats.PERCENT, + }, + { + text: i18n.translate('visTypePie.valuesFormats.value', { + defaultMessage: 'Show value', + }), + value: ValueFormats.VALUE, + }, +]; diff --git a/src/plugins/vis_type_pie/public/editor/components/index.tsx b/src/plugins/vis_type_pie/public/editor/components/index.tsx new file mode 100644 index 00000000000000..6bc31208fbdb0e --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/index.tsx @@ -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 React, { lazy } from 'react'; +import { VisEditorOptionsProps } from '../../../../visualizations/public'; +import { PieVisParams, PieTypeProps } from '../../types'; + +const PieOptionsLazy = lazy(() => import('./pie')); + +export const getPieOptions = ({ + showElasticChartsOptions, + palettes, + trackUiMetric, +}: PieTypeProps) => (props: VisEditorOptionsProps) => ( + +); diff --git a/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx b/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx new file mode 100644 index 00000000000000..524986524fd7e5 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx @@ -0,0 +1,124 @@ +/* + * 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 { mountWithIntl } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import PieOptions, { PieOptionsProps } from './pie'; +import { chartPluginMock } from '../../../../charts/public/mocks'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from 'react-dom/test-utils'; + +describe('PalettePicker', function () { + let props: PieOptionsProps; + let component: ReactWrapper; + + beforeAll(() => { + props = ({ + palettes: chartPluginMock.createSetupContract().palettes, + showElasticChartsOptions: true, + vis: { + type: { + editorConfig: { + collections: { + legendPositions: [ + { + text: 'Top', + value: 'top', + }, + { + text: 'Left', + value: 'left', + }, + { + text: 'Right', + value: 'right', + }, + { + text: 'Bottom', + value: 'bottom', + }, + ], + }, + }, + }, + }, + stateParams: { + isDonut: true, + legendPosition: 'left', + labels: { + show: true, + }, + }, + setValue: jest.fn(), + } as unknown) as PieOptionsProps; + }); + + it('renders the nested legend switch for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieNestedLegendSwitch').length).toBe(1); + }); + }); + + it('not renders the nested legend switch for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieNestedLegendSwitch').length).toBe(0); + }); + }); + + it('renders the label position dropdown for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieLabelPositionSelect').length).toBe(1); + }); + }); + + it('not renders the label position dropdown for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieLabelPositionSelect').length).toBe(0); + }); + }); + + it('renders the top level switch for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieTopLevelSwitch').length).toBe(1); + }); + }); + + it('renders the top level switch for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieTopLevelSwitch').length).toBe(1); + }); + }); + + it('renders the value format dropdown for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieValueFormatsSelect').length).toBe(1); + }); + }); + + it('not renders the value format dropdown for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieValueFormatsSelect').length).toBe(0); + }); + }); + + it('renders the percent slider for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'visTypePieValueDecimals').length).toBe(1); + }); + }); +}); diff --git a/src/plugins/vis_type_pie/public/editor/components/pie.tsx b/src/plugins/vis_type_pie/public/editor/components/pie.tsx new file mode 100644 index 00000000000000..8ce4f4defbaed8 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/pie.tsx @@ -0,0 +1,287 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { + EuiPanel, + EuiTitle, + EuiSpacer, + EuiRange, + EuiFormRow, + EuiIconTip, + EuiFlexItem, + EuiFlexGroup, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + BasicOptions, + SwitchOption, + SelectOption, + PalettePicker, +} from '../../../../vis_default_editor/public'; +import { VisEditorOptionsProps } from '../../../../visualizations/public'; +import { TruncateLabelsOption } from './truncate_labels'; +import { PaletteRegistry } from '../../../../charts/public'; +import { DEFAULT_PERCENT_DECIMALS } from '../../../common'; +import { PieVisParams, LabelPositions, ValueFormats, PieTypeProps } from '../../types'; +import { getLabelPositions, getValuesFormats } from '../collections'; +import { getLegendPositions } from '../positions'; + +export interface PieOptionsProps extends VisEditorOptionsProps, PieTypeProps {} + +function DecimalSlider({ + paramName, + value, + setValue, +}: { + value: number; + paramName: ParamName; + setValue: (paramName: ParamName, value: number) => void; +}) { + return ( + + { + setValue(paramName, Number(e.currentTarget.value)); + }} + /> + + ); +} + +const PieOptions = (props: PieOptionsProps) => { + const { stateParams, setValue, aggs } = props; + const setLabels = ( + paramName: T, + value: PieVisParams['labels'][T] + ) => setValue('labels', { ...stateParams.labels, [paramName]: value }); + const legendUiStateValue = props.uiState?.get('vis.legendOpen'); + const [palettesRegistry, setPalettesRegistry] = useState(undefined); + const [legendVisibility, setLegendVisibility] = useState(() => { + const bwcLegendStateDefault = stateParams.addLegend == null ? false : stateParams.addLegend; + return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) as boolean; + }); + const hasSplitChart = Boolean(aggs?.aggs?.find((agg) => agg.schema === 'split' && agg.enabled)); + const segments = aggs?.aggs?.filter((agg) => agg.schema === 'segment' && agg.enabled) ?? []; + + useEffect(() => { + setLegendVisibility(legendUiStateValue); + }, [legendUiStateValue]); + + useEffect(() => { + const fetchPalettes = async () => { + const palettes = await props.palettes?.getPalettes(); + setPalettesRegistry(palettes); + }; + fetchPalettes(); + }, [props.palettes]); + + return ( + <> + + +

+ +

+
+ + + + {props.showElasticChartsOptions && ( + <> + + + + + + + + + + + { + setLegendVisibility(value); + setValue(paramName, value); + }} + data-test-subj="visTypePieAddLegendSwitch" + /> + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'nested_legend_switched'); + } + setValue(paramName, value); + }} + data-test-subj="visTypePieNestedLegendSwitch" + /> + + )} + {props.showElasticChartsOptions && palettesRegistry && ( + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'palette_selected'); + } + setValue(paramName, value); + }} + /> + )} +
+ + + + + +

+ +

+
+ + + {props.showElasticChartsOptions && ( + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'label_position_selected'); + } + setLabels(paramName, value); + }} + data-test-subj="visTypePieLabelPositionSelect" + /> + )} + + + {props.showElasticChartsOptions && ( + <> + { + if (props.trackUiMetric) { + props.trackUiMetric(METRIC_TYPE.CLICK, 'values_format_selected'); + } + setLabels(paramName, value); + }} + data-test-subj="visTypePieValueFormatsSelect" + /> + + + )} + +
+ + ); +}; + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { PieOptions as default }; diff --git a/src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx new file mode 100644 index 00000000000000..1d4bb238dcb50e --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.test.tsx @@ -0,0 +1,51 @@ +/* + * 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 { mountWithIntl } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import { TruncateLabelsOption, TruncateLabelsOptionProps } from './truncate_labels'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('TruncateLabelsOption', function () { + let props: TruncateLabelsOptionProps; + let component: ReactWrapper; + + beforeAll(() => { + props = { + disabled: false, + value: 20, + setValue: jest.fn(), + }; + }); + + it('renders an input type number', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'pieLabelTruncateInput').length).toBe(1); + }); + + it('renders the value on the input number', function () { + component = mountWithIntl(); + const input = findTestSubject(component, 'pieLabelTruncateInput'); + expect(input.props().value).toBe(20); + }); + + it('disables the input if disabled prop is given', function () { + const newProps = { ...props, disabled: true }; + component = mountWithIntl(); + const input = findTestSubject(component, 'pieLabelTruncateInput'); + expect(input.props().disabled).toBe(true); + }); + + it('should set the new value', function () { + component = mountWithIntl(); + const input = findTestSubject(component, 'pieLabelTruncateInput'); + input.simulate('change', { target: { value: 100 } }); + expect(props.setValue).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx new file mode 100644 index 00000000000000..e6eb56725753c6 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.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 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, { ChangeEvent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiFieldNumber } from '@elastic/eui'; + +export interface TruncateLabelsOptionProps { + disabled?: boolean; + value?: number | null; + setValue: (paramName: 'truncate', value: null | number) => void; +} + +function TruncateLabelsOption({ disabled, value = null, setValue }: TruncateLabelsOptionProps) { + const onChange = (ev: ChangeEvent) => + setValue('truncate', ev.target.value === '' ? null : parseFloat(ev.target.value)); + + return ( + + + + ); +} + +export { TruncateLabelsOption }; diff --git a/src/plugins/vis_type_pie/public/editor/positions.ts b/src/plugins/vis_type_pie/public/editor/positions.ts new file mode 100644 index 00000000000000..ea099a23cf9b41 --- /dev/null +++ b/src/plugins/vis_type_pie/public/editor/positions.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; + +export const getLegendPositions = [ + { + text: i18n.translate('visTypePie.legendPositions.topText', { + defaultMessage: 'Top', + }), + value: Position.Top, + }, + { + text: i18n.translate('visTypePie.legendPositions.leftText', { + defaultMessage: 'Left', + }), + value: Position.Left, + }, + { + text: i18n.translate('visTypePie.legendPositions.rightText', { + defaultMessage: 'Right', + }), + value: Position.Right, + }, + { + text: i18n.translate('visTypePie.legendPositions.bottomText', { + defaultMessage: 'Bottom', + }), + value: Position.Bottom, + }, +]; diff --git a/src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts b/src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts new file mode 100644 index 00000000000000..269d5d5f779d6c --- /dev/null +++ b/src/plugins/vis_type_pie/public/expression_functions/pie_labels.ts @@ -0,0 +1,113 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + ExpressionFunctionDefinition, + Datatable, + ExpressionValueBoxed, +} from '../../../expressions/public'; + +interface Arguments { + show: boolean; + position: string; + values: boolean; + truncate: number | null; + valuesFormat: string; + lastLevel: boolean; + percentDecimals: number; +} + +export type ExpressionValuePieLabels = ExpressionValueBoxed< + 'pie_labels', + { + show: boolean; + position: string; + values: boolean; + truncate: number | null; + valuesFormat: string; + last_level: boolean; + percentDecimals: number; + } +>; + +export const pieLabels = (): ExpressionFunctionDefinition< + 'pielabels', + Datatable | null, + Arguments, + ExpressionValuePieLabels +> => ({ + name: 'pielabels', + help: i18n.translate('visTypePie.function.pieLabels.help', { + defaultMessage: 'Generates the pie labels object', + }), + type: 'pie_labels', + args: { + show: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.pieLabels.show.help', { + defaultMessage: 'Displays the pie labels', + }), + required: true, + }, + position: { + types: ['string'], + default: 'default', + help: i18n.translate('visTypePie.function.pieLabels.position.help', { + defaultMessage: 'Defines the label position', + }), + }, + values: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.pieLabels.values.help', { + defaultMessage: 'Displays the values inside the slices', + }), + default: true, + }, + percentDecimals: { + types: ['number'], + help: i18n.translate('visTypePie.function.pieLabels.percentDecimals.help', { + defaultMessage: 'Defines the number of decimals that will appear on the values as percent', + }), + default: 2, + }, + lastLevel: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.pieLabels.lastLevel.help', { + defaultMessage: 'Show top level labels only', + }), + default: true, + }, + truncate: { + types: ['number', 'null'], + help: i18n.translate('visTypePie.function.pieLabels.truncate.help', { + defaultMessage: 'Defines the number of characters that the slice value will display', + }), + default: null, + }, + valuesFormat: { + types: ['string'], + default: 'percent', + help: i18n.translate('visTypePie.function.pieLabels.valuesFormat.help', { + defaultMessage: 'Defines the format of the values', + }), + }, + }, + fn: (context, args) => { + return { + type: 'pie_labels', + show: args.show, + position: args.position, + percentDecimals: args.percentDecimals, + values: args.values, + truncate: args.truncate, + valuesFormat: args.valuesFormat, + last_level: args.lastLevel, + }; + }, +}); diff --git a/src/plugins/vis_type_pie/public/index.ts b/src/plugins/vis_type_pie/public/index.ts new file mode 100644 index 00000000000000..adf8b2d073f390 --- /dev/null +++ b/src/plugins/vis_type_pie/public/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { VisTypePiePlugin } from './plugin'; + +export { pieVisType } from './vis_type'; +export { Dimensions, Dimension } from './types'; + +export const plugin = () => new VisTypePiePlugin(); diff --git a/src/plugins/vis_type_pie/public/mocks.ts b/src/plugins/vis_type_pie/public/mocks.ts new file mode 100644 index 00000000000000..53579422e44eba --- /dev/null +++ b/src/plugins/vis_type_pie/public/mocks.ts @@ -0,0 +1,328 @@ +/* + * 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 { Datatable } from '../../expressions/public'; +import { BucketColumns, PieVisParams, LabelPositions, ValueFormats } from './types'; + +export const createMockBucketColumns = (): BucketColumns[] => { + return [ + { + id: 'col-0-2', + name: 'Carrier: Descending', + meta: { + type: 'string', + field: 'Carrier', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'Carrier', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + format: { + id: 'terms', + params: { + id: 'string', + }, + }, + }, + { + id: 'col-2-3', + name: 'Cancelled: Descending', + meta: { + type: 'boolean', + field: 'Cancelled', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'boolean', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'Cancelled', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + format: { + id: 'terms', + params: { + id: 'boolean', + }, + }, + }, + ]; +}; + +export const createMockVisData = (): Datatable => { + return { + type: 'datatable', + rows: [ + { + 'col-0-2': 'Logstash Airways', + 'col-2-3': 0, + 'col-1-1': 797, + 'col-3-1': 689, + }, + { + 'col-0-2': 'Logstash Airways', + 'col-2-3': 1, + 'col-1-1': 797, + 'col-3-1': 108, + }, + { + 'col-0-2': 'JetBeats', + 'col-2-3': 0, + 'col-1-1': 766, + 'col-3-1': 654, + }, + { + 'col-0-2': 'JetBeats', + 'col-2-3': 1, + 'col-1-1': 766, + 'col-3-1': 112, + }, + { + 'col-0-2': 'ES-Air', + 'col-2-3': 0, + 'col-1-1': 744, + 'col-3-1': 665, + }, + { + 'col-0-2': 'ES-Air', + 'col-2-3': 1, + 'col-1-1': 744, + 'col-3-1': 79, + }, + { + 'col-0-2': 'Kibana Airlines', + 'col-2-3': 0, + 'col-1-1': 731, + 'col-3-1': 655, + }, + { + 'col-0-2': 'Kibana Airlines', + 'col-2-3': 1, + 'col-1-1': 731, + 'col-3-1': 76, + }, + ], + columns: [ + { + id: 'col-0-2', + name: 'Carrier: Descending', + meta: { + type: 'string', + field: 'Carrier', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'Carrier', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + }, + { + id: 'col-1-1', + name: 'Count', + meta: { + type: 'number', + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + }, + }, + { + id: 'col-2-3', + name: 'Cancelled: Descending', + meta: { + type: 'boolean', + field: 'Cancelled', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'boolean', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'Cancelled', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + }, + }, + }, + { + id: 'col-3-1', + name: 'Count', + meta: { + type: 'number', + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + source: 'esaggs', + sourceParams: { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + }, + }, + ], + }; +}; + +export const createMockPieParams = (): PieVisParams => { + return ({ + addLegend: true, + addTooltip: true, + isDonut: true, + labels: { + position: LabelPositions.DEFAULT, + show: true, + truncate: 100, + values: true, + valuesFormat: ValueFormats.PERCENT, + percentDecimals: 2, + }, + legendPosition: 'right', + nestedLegend: false, + distinctColors: false, + palette: { + name: 'default', + type: 'palette', + }, + type: 'pie', + dimensions: { + metric: { + accessor: 1, + format: { + id: 'number', + }, + params: {}, + label: 'Count', + aggType: 'count', + }, + buckets: [ + { + accessor: 0, + format: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + label: 'Carrier: Descending', + aggType: 'terms', + }, + { + accessor: 2, + format: { + id: 'terms', + params: { + id: 'boolean', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + }, + }, + label: 'Cancelled: Descending', + aggType: 'terms', + }, + ], + }, + } as unknown) as PieVisParams; +}; diff --git a/src/plugins/vis_type_pie/public/pie_component.test.tsx b/src/plugins/vis_type_pie/public/pie_component.test.tsx new file mode 100644 index 00000000000000..177396f25adb67 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_component.test.tsx @@ -0,0 +1,123 @@ +/* + * 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 { Settings, TooltipType, SeriesIdentifier } from '@elastic/charts'; +import { chartPluginMock } from '../../charts/public/mocks'; +import { dataPluginMock } from '../../data/public/mocks'; +import { shallow, mount } from 'enzyme'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from 'react-dom/test-utils'; +import PieComponent, { PieComponentProps } from './pie_component'; +import { createMockPieParams, createMockVisData } from './mocks'; + +jest.mock('@elastic/charts', () => { + const original = jest.requireActual('@elastic/charts'); + + return { + ...original, + getSpecId: jest.fn(() => {}), + }; +}); + +const chartsThemeService = chartPluginMock.createSetupContract().theme; +const palettesRegistry = chartPluginMock.createPaletteRegistry(); +const visParams = createMockPieParams(); +const visData = createMockVisData(); + +const mockState = new Map(); +const uiState = { + get: jest + .fn() + .mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)), + set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)), + emit: jest.fn(), + setSilent: jest.fn(), +} as any; + +describe('PieComponent', function () { + let wrapperProps: PieComponentProps; + + beforeAll(() => { + wrapperProps = { + chartsThemeService, + palettesRegistry, + visParams, + visData, + uiState, + syncColors: false, + fireEvent: jest.fn(), + renderComplete: jest.fn(), + services: dataPluginMock.createStartContract(), + }; + }); + + it('renders the legend on the correct position', () => { + const component = shallow(); + expect(component.find(Settings).prop('legendPosition')).toEqual('right'); + }); + + it('renders the legend toggle component', async () => { + const component = mount(); + await act(async () => { + expect(findTestSubject(component, 'vislibToggleLegend').length).toBe(1); + }); + }); + + it('hides the legend if the legend toggle is clicked', async () => { + const component = mount(); + findTestSubject(component, 'vislibToggleLegend').simulate('click'); + await act(async () => { + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + }); + + it('defaults on showing the legend for the inner cicle', () => { + const component = shallow(); + expect(component.find(Settings).prop('legendMaxDepth')).toBe(1); + }); + + it('shows the nested legend when the user requests it', () => { + const newParams = { ...visParams, nestedLegend: true }; + const newProps = { ...wrapperProps, visParams: newParams }; + const component = shallow(); + expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined(); + }); + + it('defaults on displaying the tooltip', () => { + const component = shallow(); + expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.Follow }); + }); + + it('doesnt show the tooltip when the user requests it', () => { + const newParams = { ...visParams, addTooltip: false }; + const newProps = { ...wrapperProps, visParams: newParams }; + const component = shallow(); + expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.None }); + }); + + it('calls filter callback', () => { + const component = shallow(); + component.find(Settings).first().prop('onElementClick')!([ + [ + [ + { + groupByRollup: 6, + value: 6, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: 'Logstash Airways', + }, + ], + {} as SeriesIdentifier, + ], + ]); + expect(wrapperProps.fireEvent).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/pie_component.tsx b/src/plugins/vis_type_pie/public/pie_component.tsx new file mode 100644 index 00000000000000..b79eed2087a168 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_component.tsx @@ -0,0 +1,355 @@ +/* + * 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, { memo, useCallback, useMemo, useState, useEffect, useRef } from 'react'; + +import { + Chart, + Datum, + LayerValue, + Partition, + Position, + Settings, + RenderChangeListener, + TooltipProps, + TooltipType, + SeriesIdentifier, +} from '@elastic/charts'; +import { + LegendToggle, + ClickTriggerEvent, + ChartsPluginSetup, + PaletteRegistry, +} from '../../charts/public'; +import { DataPublicPluginStart, FieldFormat } from '../../data/public'; +import type { PersistedState } from '../../visualizations/public'; +import { Datatable, DatatableColumn, IInterpreterRenderHandlers } from '../../expressions/public'; +import { DEFAULT_PERCENT_DECIMALS } from '../common'; +import { PieVisParams, BucketColumns, ValueFormats, PieContainerDimensions } from './types'; +import { + getColorPicker, + getLayers, + getLegendActions, + canFilter, + getFilterClickData, + getFilterEventData, + getConfig, + getColumns, + getSplitDimensionAccessor, +} from './utils'; +import { ChartSplit, SMALL_MULTIPLES_ID } from './components/chart_split'; + +import './chart.scss'; + +declare global { + interface Window { + /** + * Flag used to enable debugState on elastic charts + */ + _echDebugStateFlag?: boolean; + } +} + +export interface PieComponentProps { + visParams: PieVisParams; + visData: Datatable; + uiState: PersistedState; + fireEvent: IInterpreterRenderHandlers['event']; + renderComplete: IInterpreterRenderHandlers['done']; + chartsThemeService: ChartsPluginSetup['theme']; + palettesRegistry: PaletteRegistry; + services: DataPublicPluginStart; + syncColors: boolean; +} + +const PieComponent = (props: PieComponentProps) => { + const chartTheme = props.chartsThemeService.useChartsTheme(); + const chartBaseTheme = props.chartsThemeService.useChartsBaseTheme(); + const [showLegend, setShowLegend] = useState(() => { + const bwcLegendStateDefault = + props.visParams.addLegend == null ? false : props.visParams.addLegend; + return props.uiState?.get('vis.legendOpen', bwcLegendStateDefault) as boolean; + }); + const [dimensions, setDimensions] = useState(); + + const parentRef = useRef(null); + + useEffect(() => { + if (parentRef && parentRef.current) { + const parentHeight = parentRef.current!.getBoundingClientRect().height; + const parentWidth = parentRef.current!.getBoundingClientRect().width; + setDimensions({ width: parentWidth, height: parentHeight }); + } + }, [parentRef]); + + const onRenderChange = useCallback( + (isRendered) => { + if (isRendered) { + props.renderComplete(); + } + }, + [props] + ); + + // handles slice click event + const handleSliceClick = useCallback( + ( + clickedLayers: LayerValue[], + bucketColumns: Array>, + visData: Datatable, + splitChartDimension?: DatatableColumn, + splitChartFormatter?: FieldFormat + ): void => { + const data = getFilterClickData( + clickedLayers, + bucketColumns, + visData, + splitChartDimension, + splitChartFormatter + ); + const event = { + name: 'filterBucket', + data: { data }, + }; + props.fireEvent(event); + }, + [props] + ); + + // handles legend action event data + const getLegendActionEventData = useCallback( + (visData: Datatable) => (series: SeriesIdentifier): ClickTriggerEvent | null => { + const data = getFilterEventData(visData, series); + + return { + name: 'filterBucket', + data: { + negate: false, + data, + }, + }; + }, + [] + ); + + const handleLegendAction = useCallback( + (event: ClickTriggerEvent, negate = false) => { + props.fireEvent({ + ...event, + data: { + ...event.data, + negate, + }, + }); + }, + [props] + ); + + const toggleLegend = useCallback(() => { + setShowLegend((value) => { + const newValue = !value; + props.uiState?.set('vis.legendOpen', newValue); + return newValue; + }); + }, [props.uiState]); + + useEffect(() => { + setShowLegend(props.visParams.addLegend); + props.uiState?.set('vis.legendOpen', props.visParams.addLegend); + }, [props.uiState, props.visParams.addLegend]); + + const setColor = useCallback( + (newColor: string | null, seriesLabel: string | number) => { + const colors = props.uiState?.get('vis.colors') || {}; + if (colors[seriesLabel] === newColor || !newColor) { + delete colors[seriesLabel]; + } else { + colors[seriesLabel] = newColor; + } + props.uiState?.setSilent('vis.colors', null); + props.uiState?.set('vis.colors', colors); + props.uiState?.emit('reload'); + }, + [props.uiState] + ); + + const { visData, visParams, services, syncColors } = props; + + function getSliceValue(d: Datum, metricColumn: DatatableColumn) { + if (typeof d[metricColumn.id] === 'number' && d[metricColumn.id] !== 0) { + return d[metricColumn.id]; + } + return Number.EPSILON; + } + + // formatters + const metricFieldFormatter = services.fieldFormats.deserialize( + visParams.dimensions.metric.format + ); + const splitChartFormatter = visParams.dimensions.splitColumn + ? services.fieldFormats.deserialize(visParams.dimensions.splitColumn[0].format) + : visParams.dimensions.splitRow + ? services.fieldFormats.deserialize(visParams.dimensions.splitRow[0].format) + : undefined; + const percentFormatter = services.fieldFormats.deserialize({ + id: 'percent', + params: { + pattern: `0,0.[${'0'.repeat(visParams.labels.percentDecimals ?? DEFAULT_PERCENT_DECIMALS)}]%`, + }, + }); + + const { bucketColumns, metricColumn } = useMemo(() => getColumns(visParams, visData), [ + visData, + visParams, + ]); + + const layers = useMemo( + () => + getLayers( + bucketColumns, + visParams, + props.uiState?.get('vis.colors', {}), + visData.rows, + props.palettesRegistry, + services.fieldFormats, + syncColors + ), + [ + bucketColumns, + visParams, + props.uiState, + props.palettesRegistry, + visData.rows, + services.fieldFormats, + syncColors, + ] + ); + const config = useMemo(() => getConfig(visParams, chartTheme, dimensions), [ + chartTheme, + visParams, + dimensions, + ]); + const tooltip: TooltipProps = { + type: visParams.addTooltip ? TooltipType.Follow : TooltipType.None, + }; + const legendPosition = visParams.legendPosition ?? Position.Right; + + const legendColorPicker = useMemo( + () => + getColorPicker( + legendPosition, + setColor, + bucketColumns, + visParams.palette.name, + visData.rows, + props.uiState, + visParams.distinctColors + ), + [ + legendPosition, + setColor, + bucketColumns, + visParams.palette.name, + visParams.distinctColors, + visData.rows, + props.uiState, + ] + ); + + const splitChartColumnAccessor = visParams.dimensions.splitColumn + ? getSplitDimensionAccessor( + services.fieldFormats, + visData.columns + )(visParams.dimensions.splitColumn[0]) + : undefined; + const splitChartRowAccessor = visParams.dimensions.splitRow + ? getSplitDimensionAccessor( + services.fieldFormats, + visData.columns + )(visParams.dimensions.splitRow[0]) + : undefined; + + const splitChartDimension = visParams.dimensions.splitColumn + ? visData.columns[visParams.dimensions.splitColumn[0].accessor] + : visParams.dimensions.splitRow + ? visData.columns[visParams.dimensions.splitRow[0].accessor] + : undefined; + + return ( +
+
+ + + + { + handleSliceClick( + args[0][0] as LayerValue[], + bucketColumns, + visData, + splitChartDimension, + splitChartFormatter + ); + }} + legendAction={getLegendActions( + canFilter, + getLegendActionEventData(visData), + handleLegendAction, + visParams, + services.actions, + services.fieldFormats + )} + theme={chartTheme} + baseTheme={chartBaseTheme} + onRenderChange={onRenderChange} + /> + getSliceValue(d, metricColumn)} + percentFormatter={(d: number) => percentFormatter.convert(d / 100)} + valueGetter={ + !visParams.labels.show || + visParams.labels.valuesFormat === ValueFormats.VALUE || + !visParams.labels.values + ? undefined + : 'percent' + } + valueFormatter={(d: number) => + !visParams.labels.show || !visParams.labels.values + ? '' + : metricFieldFormatter.convert(d) + } + layers={layers} + config={config} + topGroove={!visParams.labels.show ? 0 : undefined} + /> + +
+
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export default memo(PieComponent); diff --git a/src/plugins/vis_type_pie/public/pie_fn.test.ts b/src/plugins/vis_type_pie/public/pie_fn.test.ts new file mode 100644 index 00000000000000..d387d4035e8ab5 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_fn.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { functionWrapper } from '../../expressions/common/expression_functions/specs/tests/utils'; +import { createPieVisFn } from './pie_fn'; + +describe('interpreter/functions#pie', () => { + const fn = functionWrapper(createPieVisFn()); + const context = { + type: 'datatable', + rows: [{ 'col-0-1': 0 }], + columns: [{ id: 'col-0-1', name: 'Count' }], + }; + const visConfig = { + addTooltip: true, + addLegend: true, + legendPosition: 'right', + isDonut: true, + nestedLegend: true, + distinctColors: false, + palette: 'kibana_palette', + labels: { + show: false, + values: true, + position: 'default', + valuesFormat: 'percent', + percentDecimals: 2, + truncate: 100, + }, + metric: { + accessor: 0, + format: { + id: 'number', + }, + params: {}, + aggType: 'count', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an object with the correct structure', async () => { + const actual = await fn(context, visConfig); + expect(actual).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/pie_fn.ts b/src/plugins/vis_type_pie/public/pie_fn.ts new file mode 100644 index 00000000000000..1b5b8574f93117 --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_fn.ts @@ -0,0 +1,153 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition, Datatable, Render } from '../../expressions/public'; +import { PieVisParams, PieVisConfig } from './types'; + +export const vislibPieName = 'pie_vis'; + +export interface RenderValue { + visData: Datatable; + visType: string; + visConfig: PieVisParams; + syncColors: boolean; +} + +export type VisTypePieExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof vislibPieName, + Datatable, + PieVisConfig, + Render +>; + +export const createPieVisFn = (): VisTypePieExpressionFunctionDefinition => ({ + name: vislibPieName, + type: 'render', + inputTypes: ['datatable'], + help: i18n.translate('visTypePie.functions.help', { + defaultMessage: 'Pie visualization', + }), + args: { + metric: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.metricHelpText', { + defaultMessage: 'Metric dimensions config', + }), + required: true, + }, + buckets: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.bucketsHelpText', { + defaultMessage: 'Buckets dimensions config', + }), + multi: true, + }, + splitColumn: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.splitColumnHelpText', { + defaultMessage: 'Split by column dimension config', + }), + multi: true, + }, + splitRow: { + types: ['vis_dimension'], + help: i18n.translate('visTypePie.function.args.splitRowHelpText', { + defaultMessage: 'Split by row dimension config', + }), + multi: true, + }, + addTooltip: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.addTooltipHelpText', { + defaultMessage: 'Show tooltip on slice hover', + }), + default: true, + }, + addLegend: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.addLegendHelpText', { + defaultMessage: 'Show legend chart legend', + }), + }, + legendPosition: { + types: ['string'], + help: i18n.translate('visTypePie.function.args.legendPositionHelpText', { + defaultMessage: 'Position the legend on top, bottom, left, right of the chart', + }), + }, + nestedLegend: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.nestedLegendHelpText', { + defaultMessage: 'Show a more detailed legend', + }), + default: false, + }, + distinctColors: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.distinctColorsHelpText', { + defaultMessage: + 'Maps different color per slice. Slices with the same value have the same color', + }), + default: false, + }, + isDonut: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.isDonutHelpText', { + defaultMessage: 'Displays the pie chart as donut', + }), + default: false, + }, + palette: { + types: ['string'], + help: i18n.translate('visTypePie.function.args.paletteHelpText', { + defaultMessage: 'Defines the chart palette name', + }), + default: 'default', + }, + labels: { + types: ['pie_labels'], + help: i18n.translate('visTypePie.function.args.labelsHelpText', { + defaultMessage: 'Pie labels config', + }), + }, + }, + fn(context, args, handlers) { + const visConfig = { + ...args, + palette: { + type: 'palette', + name: args.palette, + }, + dimensions: { + metric: args.metric, + buckets: args.buckets, + splitColumn: args.splitColumn, + splitRow: args.splitRow, + }, + } as PieVisParams; + + if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.logDatatable('default', context); + } + + return { + type: 'render', + as: vislibPieName, + value: { + visData: context, + visConfig, + syncColors: handlers?.isSyncColorsEnabled?.() ?? false, + visType: 'pie', + params: { + listenOnChange: true, + }, + }, + }; + }, +}); diff --git a/src/plugins/vis_type_pie/public/pie_renderer.tsx b/src/plugins/vis_type_pie/public/pie_renderer.tsx new file mode 100644 index 00000000000000..bcd4cad4efa66f --- /dev/null +++ b/src/plugins/vis_type_pie/public/pie_renderer.tsx @@ -0,0 +1,63 @@ +/* + * 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, { lazy } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ExpressionRenderDefinition } from '../../expressions/public'; +import { VisualizationContainer } from '../../visualizations/public'; +import type { PersistedState } from '../../visualizations/public'; +import { VisTypePieDependencies } from './plugin'; + +import { RenderValue, vislibPieName } from './pie_fn'; + +const PieComponent = lazy(() => import('./pie_component')); + +function shouldShowNoResultsMessage(visData: any): boolean { + const rows: object[] | undefined = visData?.rows; + const isZeroHits = visData?.hits === 0 || (rows && !rows.length); + + return Boolean(isZeroHits); +} + +export const getPieVisRenderer: ( + deps: VisTypePieDependencies +) => ExpressionRenderDefinition = ({ theme, palettes, getStartDeps }) => ({ + name: vislibPieName, + displayName: 'Pie visualization', + reuseDomNode: true, + render: async (domNode, { visConfig, visData, syncColors }, handlers) => { + const showNoResult = shouldShowNoResultsMessage(visData); + + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + const services = await getStartDeps(); + const palettesRegistry = await palettes.getPalettes(); + + render( + + + + + , + domNode + ); + }, +}); diff --git a/src/plugins/vis_type_pie/public/plugin.ts b/src/plugins/vis_type_pie/public/plugin.ts new file mode 100644 index 00000000000000..440a3a75a2eb19 --- /dev/null +++ b/src/plugins/vis_type_pie/public/plugin.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { CoreSetup, DocLinksStart } from 'src/core/public'; +import { VisualizationsSetup } from '../../visualizations/public'; +import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; +import { ChartsPluginSetup } from '../../charts/public'; +import { UsageCollectionSetup } from '../../usage_collection/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; +import { pieLabels as pieLabelsExpressionFunction } from './expression_functions/pie_labels'; +import { createPieVisFn } from './pie_fn'; +import { getPieVisRenderer } from './pie_renderer'; +import { pieVisType } from './vis_type'; + +/** @internal */ +export interface VisTypePieSetupDependencies { + visualizations: VisualizationsSetup; + expressions: ReturnType; + charts: ChartsPluginSetup; + usageCollection: UsageCollectionSetup; +} + +/** @internal */ +export interface VisTypePiePluginStartDependencies { + data: DataPublicPluginStart; +} + +/** @internal */ +export interface VisTypePieDependencies { + theme: ChartsPluginSetup['theme']; + palettes: ChartsPluginSetup['palettes']; + getStartDeps: () => Promise<{ data: DataPublicPluginStart; docLinks: DocLinksStart }>; +} + +export class VisTypePiePlugin { + setup( + core: CoreSetup, + { expressions, visualizations, charts, usageCollection }: VisTypePieSetupDependencies + ) { + if (!core.uiSettings.get(LEGACY_CHARTS_LIBRARY, false)) { + const getStartDeps = async () => { + const [coreStart, deps] = await core.getStartServices(); + return { + data: deps.data, + docLinks: coreStart.docLinks, + }; + }; + const trackUiMetric = usageCollection?.reportUiCounter.bind(usageCollection, 'vis_type_pie'); + + expressions.registerFunction(createPieVisFn); + expressions.registerRenderer( + getPieVisRenderer({ theme: charts.theme, palettes: charts.palettes, getStartDeps }) + ); + expressions.registerFunction(pieLabelsExpressionFunction); + visualizations.createBaseVisualization( + pieVisType({ + showElasticChartsOptions: true, + palettes: charts.palettes, + trackUiMetric, + }) + ); + } + return {}; + } + + start() {} +} diff --git a/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts new file mode 100644 index 00000000000000..3b07743e79f457 --- /dev/null +++ b/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts @@ -0,0 +1,1332 @@ +/* + * 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 samplePieVis = { + type: { + name: 'pie', + title: 'Pie', + description: 'Compare parts of a whole', + icon: 'visPie', + stage: 'production', + options: { + showTimePicker: true, + showQueryBar: true, + showFilterBar: true, + showIndexSelection: true, + hierarchicalData: false, + }, + visConfig: { + defaults: { + type: 'pie', + addTooltip: true, + addLegend: true, + legendPosition: 'right', + isDonut: true, + nestedLegend: true, + distinctColors: false, + palette: 'kibana_palette', + labels: { + show: false, + values: true, + last_level: true, + valuesFormat: 'percent', + percentDecimals: 2, + truncate: 100, + }, + }, + }, + editorConfig: { + collections: { + legendPositions: [ + { + text: 'Top', + value: 'top', + }, + { + text: 'Left', + value: 'left', + }, + { + text: 'Right', + value: 'right', + }, + { + text: 'Bottom', + value: 'bottom', + }, + ], + }, + schemas: { + all: [ + { + group: 'metrics', + name: 'metric', + title: 'Slice size', + min: 1, + max: 1, + aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], + defaults: [ + { + schema: 'metric', + type: 'count', + }, + ], + editor: false, + params: [], + }, + { + group: 'buckets', + name: 'segment', + title: 'Split slices', + min: 0, + max: null, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + editor: false, + params: [], + }, + { + group: 'buckets', + name: 'split', + title: 'Split chart', + mustBeFirst: true, + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + params: [ + { + name: 'row', + default: true, + }, + ], + editor: false, + }, + ], + buckets: [null, null], + metrics: [null], + }, + }, + hidden: false, + hierarchicalData: true, + }, + title: '[Flights] Airline Carrier', + description: '', + params: { + type: 'pie', + addTooltip: true, + addLegend: true, + legendPosition: 'right', + isDonut: true, + labels: { + show: true, + values: true, + last_level: true, + truncate: 100, + }, + }, + data: { + indexPattern: { id: '123' }, + searchSource: { + id: 'data_source1', + requestStartHandlers: [], + inheritOptions: {}, + history: [], + fields: { + filter: [], + query: { + query: '', + language: 'kuery', + }, + index: { + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + title: 'kibana_sample_data_flights', + fieldFormatMap: { + AvgTicketPrice: { + id: 'number', + params: { + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + pattern: '$0,0.[00]', + }, + }, + hour_of_day: { + id: 'number', + params: { + pattern: '00', + }, + }, + }, + fields: [ + { + count: 0, + name: 'AvgTicketPrice', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Cancelled', + type: 'boolean', + esTypes: ['boolean'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Carrier', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Dest', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestAirportID', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestCityName', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestCountry', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestLocation', + type: 'geo_point', + esTypes: ['geo_point'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestRegion', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DestWeather', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DistanceKilometers', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'DistanceMiles', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightDelay', + type: 'boolean', + esTypes: ['boolean'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightDelayMin', + type: 'number', + esTypes: ['integer'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightDelayType', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightNum', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightTimeHour', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightTimeMin', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'Origin', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginAirportID', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginCityName', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginCountry', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginLocation', + type: 'geo_point', + esTypes: ['geo_point'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginRegion', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginWeather', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: '_id', + type: 'string', + esTypes: ['_id'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: '_index', + type: 'string', + esTypes: ['_index'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: '_score', + type: 'number', + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_source', + type: '_source', + esTypes: ['_source'], + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + count: 0, + name: '_type', + type: 'string', + esTypes: ['_type'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + count: 0, + name: 'dayOfWeek', + type: 'number', + esTypes: ['integer'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'timestamp', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + script: "doc['timestamp'].value.hourOfDay", + lang: 'painless', + name: 'hour_of_day', + type: 'number', + scripted: true, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + ], + timeFieldName: 'timestamp', + metaFields: ['_source', '_id', '_type', '_index', '_score'], + version: 'WzM1LDFd', + originalSavedObjectBody: { + title: 'kibana_sample_data_flights', + timeFieldName: 'timestamp', + fields: + '[{"count":0,"name":"AvgTicketPrice","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Cancelled","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Carrier","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Dest","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceKilometers","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceMiles","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelay","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayMin","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayType","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightNum","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeHour","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeMin","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Origin","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"_id","type":"string","esTypes":["_id"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_index","type":"string","esTypes":["_index"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_score","type":"number","scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_source","type":"_source","esTypes":["_source"],"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_type","type":"string","esTypes":["_type"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"dayOfWeek","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"timestamp","type":"date","esTypes":["date"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', + fieldFormatMap: + '{"AvgTicketPrice":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"$0,0.[00]"}},"hour_of_day":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"00"}}}', + }, + shortDotsEnable: false, + fieldFormats: { + fieldFormats: {}, + defaultMap: { + ip: { + id: 'ip', + params: {}, + }, + date: { + id: 'date', + params: {}, + }, + date_nanos: { + id: 'date_nanos', + params: {}, + es: true, + }, + number: { + id: 'number', + params: {}, + }, + boolean: { + id: 'boolean', + params: {}, + }, + _source: { + id: '_source', + params: {}, + }, + _default_: { + id: 'string', + params: {}, + }, + }, + metaParamsOptions: {}, + }, + }, + }, + dependencies: { + legacy: { + loadingCount$: { + _isScalar: false, + observers: [ + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + destination: { + closed: true, + }, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [ + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 13, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 1, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: { + closed: false, + _parentOrParents: null, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + destination: { + closed: false, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + _context: {}, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + count: 3, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: false, + hasPrev: true, + prev: 0, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: true, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [], + active: 1, + index: 2, + }, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + parent: { + closed: true, + _parentOrParents: null, + _subscriptions: null, + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: true, + concurrent: 1, + hasCompleted: true, + buffer: [ + { + _isScalar: false, + }, + ], + active: 1, + index: 1, + }, + }, + _subscriptions: [ + null, + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + subject: { + _isScalar: false, + observers: [null], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + null, + ], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + }, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + null, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + seenValue: false, + }, + _subscriptions: [null], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + }, + _subscriptions: [ + { + closed: false, + _subscriptions: null, + }, + ], + syncErrorValue: null, + syncErrorThrown: false, + syncErrorThrowable: false, + isStopped: false, + hasKey: true, + key: 0, + }, + ], + closed: false, + isStopped: false, + hasError: false, + thrownError: null, + _value: 0, + }, + }, + }, + }, + aggs: { + typesRegistry: {}, + getResponseAggs: () => [ + { + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + toSerializedFieldFormat: () => ({ + id: 'number', + }), + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'Carrier', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'segment', + toSerializedFieldFormat: () => ({ + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5801', + pathname: '/app/visualize', + basePath: '', + }, + }, + }), + }, + ], + aggs: [], + }, + }, + isHierarchical: () => true, + uiState: { + vis: { + legendOpen: false, + }, + }, +}; diff --git a/src/plugins/vis_type_pie/public/to_ast.test.ts b/src/plugins/vis_type_pie/public/to_ast.test.ts new file mode 100644 index 00000000000000..019c6e21767105 --- /dev/null +++ b/src/plugins/vis_type_pie/public/to_ast.test.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 { Vis } from '../../visualizations/public'; + +import { PieVisParams } from './types'; +import { samplePieVis } from './sample_vis.test.mocks'; +import { toExpressionAst } from './to_ast'; + +describe('vis type pie vis toExpressionAst function', () => { + let vis: Vis; + const params = { + timefilter: {}, + timeRange: {}, + abortSignal: {}, + } as any; + + beforeEach(() => { + vis = samplePieVis as any; + }); + + it('should match basic snapshot', async () => { + const actual = await toExpressionAst(vis, params); + expect(actual).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/vis_type_pie/public/to_ast.ts b/src/plugins/vis_type_pie/public/to_ast.ts new file mode 100644 index 00000000000000..e8c9f301b4366d --- /dev/null +++ b/src/plugins/vis_type_pie/public/to_ast.ts @@ -0,0 +1,71 @@ +/* + * 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 { getVisSchemas, VisToExpressionAst, SchemaConfig } from '../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../expressions/public'; +import { PieVisParams, LabelsParams } from './types'; +import { vislibPieName, VisTypePieExpressionFunctionDefinition } from './pie_fn'; +import { getEsaggsFn } from './to_ast_esaggs'; + +const prepareDimension = (params: SchemaConfig) => { + const visdimension = buildExpressionFunction('visdimension', { accessor: params.accessor }); + + if (params.format) { + visdimension.addArgument('format', params.format.id); + visdimension.addArgument('formatParams', JSON.stringify(params.format.params)); + } + + return buildExpression([visdimension]); +}; + +const prepareLabels = (params: LabelsParams) => { + const pieLabels = buildExpressionFunction('pielabels', { + show: params.show, + lastLevel: params.last_level, + values: params.values, + truncate: params.truncate, + }); + if (params.position) { + pieLabels.addArgument('position', params.position); + } + if (params.valuesFormat) { + pieLabels.addArgument('valuesFormat', params.valuesFormat); + } + if (params.percentDecimals != null) { + pieLabels.addArgument('percentDecimals', params.percentDecimals); + } + return buildExpression([pieLabels]); +}; + +export const toExpressionAst: VisToExpressionAst = async (vis, params) => { + const schemas = getVisSchemas(vis, params); + const args = { + // explicitly pass each param to prevent extra values trapping + addTooltip: vis.params.addTooltip, + addLegend: vis.params.addLegend, + legendPosition: vis.params.legendPosition, + nestedLegend: vis.params?.nestedLegend, + distinctColors: vis.params?.distinctColors, + isDonut: vis.params.isDonut, + palette: vis.params?.palette?.name, + labels: prepareLabels(vis.params.labels), + metric: schemas.metric.map(prepareDimension), + buckets: schemas.segment?.map(prepareDimension), + splitColumn: schemas.split_column?.map(prepareDimension), + splitRow: schemas.split_row?.map(prepareDimension), + }; + + const visTypePie = buildExpressionFunction( + vislibPieName, + args + ); + + const ast = buildExpression([getEsaggsFn(vis), visTypePie]); + + return ast.toAst(); +}; diff --git a/src/plugins/vis_type_pie/public/to_ast_esaggs.ts b/src/plugins/vis_type_pie/public/to_ast_esaggs.ts new file mode 100644 index 00000000000000..9b760bd4bebcc0 --- /dev/null +++ b/src/plugins/vis_type_pie/public/to_ast_esaggs.ts @@ -0,0 +1,33 @@ +/* + * 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 { Vis } from '../../visualizations/public'; +import { buildExpression, buildExpressionFunction } from '../../expressions/public'; +import { + EsaggsExpressionFunctionDefinition, + IndexPatternLoadExpressionFunctionDefinition, +} from '../../data/public'; + +import { PieVisParams } from './types'; + +/** + * Get esaggs expressions function + * @param vis + */ +export function getEsaggsFn(vis: Vis) { + return buildExpressionFunction('esaggs', { + index: buildExpression([ + buildExpressionFunction('indexPatternLoad', { + id: vis.data.indexPattern!.id!, + }), + ]), + metricsAtAllLevels: vis.isHierarchical(), + partialRows: false, + aggs: vis.data.aggs!.aggs.map((agg) => buildExpression(agg.toExpressionAst())), + }); +} diff --git a/src/plugins/vis_type_pie/public/types/index.ts b/src/plugins/vis_type_pie/public/types/index.ts new file mode 100644 index 00000000000000..12594660136d8f --- /dev/null +++ b/src/plugins/vis_type_pie/public/types/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './types'; diff --git a/src/plugins/vis_type_pie/public/types/types.ts b/src/plugins/vis_type_pie/public/types/types.ts new file mode 100644 index 00000000000000..4f3365545d0628 --- /dev/null +++ b/src/plugins/vis_type_pie/public/types/types.ts @@ -0,0 +1,96 @@ +/* + * 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 { Position } from '@elastic/charts'; +import { UiCounterMetricType } from '@kbn/analytics'; +import { DatatableColumn, SerializedFieldFormat } from '../../../expressions/public'; +import { ExpressionValueVisDimension } from '../../../visualizations/public'; +import { ExpressionValuePieLabels } from '../expression_functions/pie_labels'; +import { PaletteOutput, ChartsPluginSetup } from '../../../charts/public'; + +export interface Dimension { + accessor: number; + format: { + id?: string; + params?: SerializedFieldFormat; + }; +} + +export interface Dimensions { + metric: Dimension; + buckets?: Dimension[]; + splitRow?: Dimension[]; + splitColumn?: Dimension[]; +} + +interface PieCommonParams { + addTooltip: boolean; + addLegend: boolean; + legendPosition: Position; + nestedLegend: boolean; + distinctColors: boolean; + isDonut: boolean; +} + +export interface LabelsParams { + show: boolean; + last_level: boolean; + position: LabelPositions; + values: boolean; + truncate: number | null; + valuesFormat: ValueFormats; + percentDecimals: number; +} + +export interface PieVisParams extends PieCommonParams { + dimensions: Dimensions; + labels: LabelsParams; + palette: PaletteOutput; +} + +export interface PieVisConfig extends PieCommonParams { + buckets?: ExpressionValueVisDimension[]; + metric: ExpressionValueVisDimension; + splitColumn?: ExpressionValueVisDimension[]; + splitRow?: ExpressionValueVisDimension[]; + labels: ExpressionValuePieLabels; + palette: string; +} + +export interface BucketColumns extends DatatableColumn { + format?: { + id?: string; + params?: SerializedFieldFormat; + }; +} + +export enum LabelPositions { + INSIDE = 'inside', + DEFAULT = 'default', +} + +export enum ValueFormats { + PERCENT = 'percent', + VALUE = 'value', +} + +export interface PieTypeProps { + showElasticChartsOptions?: boolean; + palettes?: ChartsPluginSetup['palettes']; + trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; +} + +export interface SplitDimensionParams { + order?: string; + orderBy?: string; +} + +export interface PieContainerDimensions { + width: number; + height: number; +} diff --git a/src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts b/src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts new file mode 100644 index 00000000000000..3f532cf4c384ff --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/filter_helpers.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { DatatableColumn } from '../../../expressions/public'; +import { getFilterClickData, getFilterEventData } from './filter_helpers'; +import { createMockBucketColumns, createMockVisData } from '../mocks'; + +const bucketColumns = createMockBucketColumns(); +const visData = createMockVisData(); + +describe('getFilterClickData', () => { + it('returns the correct filter data for the specific layer', () => { + const clickedLayers = [ + { + groupByRollup: 'Logstash Airways', + value: 729, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, + ]; + const data = getFilterClickData(clickedLayers, bucketColumns, visData); + expect(data.length).toEqual(clickedLayers.length); + expect(data[0].value).toEqual('Logstash Airways'); + expect(data[0].row).toEqual(0); + expect(data[0].column).toEqual(0); + }); + + it('changes the filter if the user clicks on another layer', () => { + const clickedLayers = [ + { + groupByRollup: 'ES-Air', + value: 572, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: '', + }, + ]; + const data = getFilterClickData(clickedLayers, bucketColumns, visData); + expect(data.length).toEqual(clickedLayers.length); + expect(data[0].value).toEqual('ES-Air'); + expect(data[0].row).toEqual(4); + expect(data[0].column).toEqual(0); + }); + + it('returns the correct filters for small multiples', () => { + const clickedLayers = [ + { + groupByRollup: 'ES-Air', + value: 572, + depth: 1, + path: [], + sortIndex: 1, + smAccessorValue: 1, + }, + ]; + const splitDimension = { + id: 'col-2-3', + name: 'Cancelled: Descending', + } as DatatableColumn; + const data = getFilterClickData(clickedLayers, bucketColumns, visData, splitDimension); + expect(data.length).toEqual(2); + expect(data[0].value).toEqual('ES-Air'); + expect(data[0].row).toEqual(5); + expect(data[0].column).toEqual(0); + expect(data[1].value).toEqual(1); + }); +}); + +describe('getFilterEventData', () => { + it('returns the correct filter data for the specific series', () => { + const series = { + key: 'Kibana Airlines', + specId: 'pie', + }; + const data = getFilterEventData(visData, series); + expect(data[0].value).toEqual('Kibana Airlines'); + expect(data[0].row).toEqual(6); + expect(data[0].column).toEqual(0); + }); + + it('changes the filter if the user clicks on another series', () => { + const series = { + key: 'JetBeats', + specId: 'pie', + }; + const data = getFilterEventData(visData, series); + expect(data[0].value).toEqual('JetBeats'); + expect(data[0].row).toEqual(2); + expect(data[0].column).toEqual(0); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/filter_helpers.ts b/src/plugins/vis_type_pie/public/utils/filter_helpers.ts new file mode 100644 index 00000000000000..251ff8acc698e9 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/filter_helpers.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 { LayerValue, SeriesIdentifier } from '@elastic/charts'; +import { Datatable, DatatableColumn } from '../../../expressions/public'; +import { DataPublicPluginStart, FieldFormat } from '../../../data/public'; +import { ClickTriggerEvent } from '../../../charts/public'; +import { ValueClickContext } from '../../../embeddable/public'; +import { BucketColumns } from '../types'; + +export const canFilter = async ( + event: ClickTriggerEvent | null, + actions: DataPublicPluginStart['actions'] +): Promise => { + if (!event) { + return false; + } + const filters = await actions.createFiltersFromValueClickAction(event.data); + return Boolean(filters.length); +}; + +export const getFilterClickData = ( + clickedLayers: LayerValue[], + bucketColumns: Array>, + visData: Datatable, + splitChartDimension?: DatatableColumn, + splitChartFormatter?: FieldFormat +): ValueClickContext['data']['data'] => { + const data: ValueClickContext['data']['data'] = []; + const matchingIndex = visData.rows.findIndex((row) => + clickedLayers.every((layer, index) => { + const columnId = bucketColumns[index].id; + if (!columnId) return; + const isCurrentLayer = row[columnId] === layer.groupByRollup; + if (!splitChartDimension) { + return isCurrentLayer; + } + const value = + splitChartFormatter?.convert(row[splitChartDimension.id]) || row[splitChartDimension.id]; + return isCurrentLayer && value === layer.smAccessorValue; + }) + ); + + data.push( + ...clickedLayers.map((clickedLayer, index) => ({ + column: visData.columns.findIndex((col) => col.id === bucketColumns[index].id), + row: matchingIndex, + value: clickedLayer.groupByRollup, + table: visData, + })) + ); + + // Allows filtering with the small multiples value + if (splitChartDimension) { + data.push({ + column: visData.columns.findIndex((col) => col.id === splitChartDimension.id), + row: matchingIndex, + table: visData, + value: clickedLayers[0].smAccessorValue, + }); + } + + return data; +}; + +export const getFilterEventData = ( + visData: Datatable, + series: SeriesIdentifier +): ValueClickContext['data']['data'] => { + return visData.columns.reduce((acc, { id }, column) => { + const value = series.key; + const row = visData.rows.findIndex((r) => r[id] === value); + if (row > -1) { + acc.push({ + table: visData, + column, + row, + value, + }); + } + + return acc; + }, []); +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx b/src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx new file mode 100644 index 00000000000000..5e9087947b95e7 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_color_picker.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { LegendColorPickerProps } from '@elastic/charts'; +import { EuiPopover } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test/jest'; +import { ComponentType, ReactWrapper } from 'enzyme'; +import { getColorPicker } from './get_color_picker'; +import { ColorPicker } from '../../../charts/public'; +import type { PersistedState } from '../../../visualizations/public'; +import { createMockBucketColumns, createMockVisData } from '../mocks'; + +const bucketColumns = createMockBucketColumns(); +const visData = createMockVisData(); + +jest.mock('@elastic/charts', () => { + const original = jest.requireActual('@elastic/charts'); + + return { + ...original, + getSpecId: jest.fn(() => {}), + }; +}); + +describe('getColorPicker', function () { + const mockState = new Map(); + const uiState = ({ + get: jest + .fn() + .mockImplementation((key, fallback) => (mockState.has(key) ? mockState.get(key) : fallback)), + set: jest.fn().mockImplementation((key, value) => mockState.set(key, value)), + emit: jest.fn(), + setSilent: jest.fn(), + } as unknown) as PersistedState; + + let wrapperProps: LegendColorPickerProps; + const Component: ComponentType = getColorPicker( + 'left', + jest.fn(), + bucketColumns, + 'default', + visData.rows, + uiState, + false + ); + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapperProps = { + color: 'rgb(109, 204, 177)', + onClose: jest.fn(), + onChange: jest.fn(), + anchor: document.createElement('div'), + seriesIdentifiers: [ + { + key: 'Logstash Airways', + specId: 'pie', + }, + ], + }; + }); + + it('renders the color picker for default palette and inner layer', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).length).toBe(1); + }); + + it('renders the picker on the correct position', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(EuiPopover).prop('anchorPosition')).toEqual('rightCenter'); + }); + + it('converts the color to the right hex and passes it to the color picker', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('color')).toEqual('#6dccb1'); + }); + + it('doesnt render the picker for default palette and not inner layer', () => { + const newProps = { ...wrapperProps, seriesIdentifier: { key: '1', specId: 'pie' } }; + wrapper = mountWithIntl(); + expect(wrapper).toEqual({}); + }); + + it('renders the color picker with the colorIsOverwritten prop set to false if color is not overwritten for the specific series', () => { + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('colorIsOverwritten')).toBe(false); + }); + + it('renders the color picker with the colorIsOverwritten prop set to true if color is overwritten for the specific series', () => { + uiState.set('vis.colors', { 'Logstash Airways': '#6092c0' }); + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).prop('colorIsOverwritten')).toBe(true); + }); + + it('renders the picker for kibana palette and not distinctColors', () => { + const LegacyPaletteComponent: ComponentType = getColorPicker( + 'left', + jest.fn(), + bucketColumns, + 'kibana_palette', + visData.rows, + uiState, + true + ); + const newProps = { ...wrapperProps, seriesIdentifier: { key: '1', specId: 'pie' } }; + wrapper = mountWithIntl(); + expect(wrapper.find(ColorPicker).length).toBe(1); + expect(wrapper.find(ColorPicker).prop('useLegacyColors')).toBe(true); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx b/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx new file mode 100644 index 00000000000000..436ce81d3ce3c5 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_color_picker.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback } from 'react'; +import Color from 'color'; +import { LegendColorPicker, Position } from '@elastic/charts'; +import { PopoverAnchorPosition, EuiPopover, EuiOutsideClickDetector } from '@elastic/eui'; +import { DatatableRow } from '../../../expressions/public'; +import type { PersistedState } from '../../../visualizations/public'; +import { ColorPicker } from '../../../charts/public'; +import { BucketColumns } from '../types'; + +const KEY_CODE_ENTER = 13; + +function getAnchorPosition(legendPosition: Position): PopoverAnchorPosition { + switch (legendPosition) { + case Position.Bottom: + return 'upCenter'; + case Position.Top: + return 'downCenter'; + case Position.Left: + return 'rightCenter'; + default: + return 'leftCenter'; + } +} + +function getLayerIndex( + seriesKey: string, + data: DatatableRow[], + layers: Array> +): number { + const row = data.find((d) => Object.keys(d).find((key) => d[key] === seriesKey)); + const bucketId = row && Object.keys(row).find((key) => row[key] === seriesKey); + return layers.findIndex((layer) => layer.id === bucketId) + 1; +} + +function isOnInnerLayer( + firstBucket: Partial, + data: DatatableRow[], + seriesKey: string +): DatatableRow | undefined { + return data.find((d) => firstBucket.id && d[firstBucket.id] === seriesKey); +} + +export const getColorPicker = ( + legendPosition: Position, + setColor: (newColor: string | null, seriesKey: string | number) => void, + bucketColumns: Array>, + palette: string, + data: DatatableRow[], + uiState: PersistedState, + distinctColors: boolean +): LegendColorPicker => ({ + anchor, + color, + onClose, + onChange, + seriesIdentifiers: [seriesIdentifier], +}) => { + const seriesName = seriesIdentifier.key; + const overwriteColors: Record = uiState?.get('vis.colors', {}) ?? {}; + const colorIsOverwritten = Object.keys(overwriteColors).includes(seriesName.toString()); + let keyDownEventOn = false; + const handleChange = (newColor: string | null) => { + if (newColor) { + onChange(newColor); + } + setColor(newColor, seriesName); + // close the popover if no color is applied or the user has clicked a color + if (!newColor || !keyDownEventOn) { + onClose(); + } + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === KEY_CODE_ENTER) { + onClose?.(); + } + keyDownEventOn = true; + }; + + const handleOutsideClick = useCallback(() => { + onClose?.(); + }, [onClose]); + + if (!distinctColors) { + const enablePicker = isOnInnerLayer(bucketColumns[0], data, seriesName) || !bucketColumns[0].id; + if (!enablePicker) return null; + } + const hexColor = new Color(color).hex(); + return ( + + + + + + ); +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_columns.test.ts b/src/plugins/vis_type_pie/public/utils/get_columns.test.ts new file mode 100644 index 00000000000000..3170628ec2e125 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_columns.test.ts @@ -0,0 +1,222 @@ +/* + * 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 { getColumns } from './get_columns'; +import { PieVisParams } from '../types'; +import { createMockPieParams, createMockVisData } from '../mocks'; + +const visParams = createMockPieParams(); +const visData = createMockVisData(); + +describe('getColumns', () => { + it('should return the correct bucket columns if visParams returns dimensions', () => { + const { bucketColumns } = getColumns(visParams, visData); + expect(bucketColumns.length).toEqual(visParams.dimensions.buckets?.length); + expect(bucketColumns).toEqual([ + { + format: { + id: 'terms', + params: { + id: 'string', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + id: 'col-0-2', + meta: { + field: 'Carrier', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '2', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: { + field: 'Carrier', + missingBucket: false, + missingBucketLabel: 'Missing', + order: 'desc', + orderBy: '1', + otherBucket: false, + otherBucketLabel: 'Other', + size: 5, + }, + schema: 'segment', + type: 'terms', + }, + type: 'string', + }, + name: 'Carrier: Descending', + }, + { + format: { + id: 'terms', + params: { + id: 'boolean', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + id: 'col-2-3', + meta: { + field: 'Cancelled', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'boolean', + missingBucketLabel: 'Missing', + otherBucketLabel: 'Other', + }, + }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '3', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: { + field: 'Cancelled', + missingBucket: false, + missingBucketLabel: 'Missing', + order: 'desc', + orderBy: '1', + otherBucket: false, + otherBucketLabel: 'Other', + size: 5, + }, + schema: 'segment', + type: 'terms', + }, + type: 'boolean', + }, + name: 'Cancelled: Descending', + }, + ]); + }); + + it('should return the correct metric column if visParams returns dimensions', () => { + const { metricColumn } = getColumns(visParams, visData); + expect(metricColumn).toEqual({ + id: 'col-3-1', + meta: { + index: 'kibana_sample_data_flights', + params: { id: 'number' }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '1', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: {}, + schema: 'metric', + type: 'count', + }, + type: 'number', + }, + name: 'Count', + }); + }); + + it('should return the first data column if no buckets specified', () => { + const visParamsOnlyMetric = ({ + addLegend: true, + addTooltip: true, + isDonut: true, + labels: { + position: 'default', + show: true, + truncate: 100, + values: true, + valuesFormat: 'percent', + percentDecimals: 2, + }, + legendPosition: 'right', + nestedLegend: false, + palette: { + name: 'default', + type: 'palette', + }, + type: 'pie', + dimensions: { + metric: { + accessor: 1, + format: { + id: 'number', + }, + params: {}, + label: 'Count', + aggType: 'count', + }, + }, + } as unknown) as PieVisParams; + const { metricColumn } = getColumns(visParamsOnlyMetric, visData); + expect(metricColumn).toEqual({ + id: 'col-1-1', + meta: { + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + source: 'esaggs', + sourceParams: { + enabled: true, + id: '1', + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + params: {}, + schema: 'metric', + type: 'count', + }, + type: 'number', + }, + name: 'Count', + }); + }); + + it('should return an object with the name of the metric if no buckets specified', () => { + const visParamsOnlyMetric = ({ + addLegend: true, + addTooltip: true, + isDonut: true, + labels: { + position: 'default', + show: true, + truncate: 100, + values: true, + valuesFormat: 'percent', + percentDecimals: 2, + }, + legendPosition: 'right', + nestedLegend: false, + palette: { + name: 'default', + type: 'palette', + }, + type: 'pie', + dimensions: { + metric: { + accessor: 1, + format: { + id: 'number', + }, + params: {}, + label: 'Count', + aggType: 'count', + }, + }, + } as unknown) as PieVisParams; + const { bucketColumns, metricColumn } = getColumns(visParamsOnlyMetric, visData); + expect(bucketColumns).toEqual([{ name: metricColumn.name }]); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_columns.ts b/src/plugins/vis_type_pie/public/utils/get_columns.ts new file mode 100644 index 00000000000000..4a32466d808da1 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_columns.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { DatatableColumn, Datatable } from '../../../expressions/public'; +import { BucketColumns, PieVisParams } from '../types'; + +export const getColumns = ( + visParams: PieVisParams, + visData: Datatable +): { + metricColumn: DatatableColumn; + bucketColumns: Array>; +} => { + if (visParams.dimensions.buckets && visParams.dimensions.buckets.length > 0) { + const bucketColumns: Array> = visParams.dimensions.buckets.map( + ({ accessor, format }) => ({ + ...visData.columns[accessor], + format, + }) + ); + const lastBucketId = bucketColumns[bucketColumns.length - 1].id; + const matchingIndex = visData.columns.findIndex((col) => col.id === lastBucketId); + return { + bucketColumns, + metricColumn: visData.columns[matchingIndex + 1], + }; + } + const metricAccessor = visParams?.dimensions?.metric.accessor ?? 0; + const metricColumn = visData.columns[metricAccessor]; + return { + metricColumn, + bucketColumns: [ + { + name: metricColumn.name, + }, + ], + }; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_config.ts b/src/plugins/vis_type_pie/public/utils/get_config.ts new file mode 100644 index 00000000000000..a8a4edb01cd9c8 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_config.ts @@ -0,0 +1,76 @@ +/* + * 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 { PartitionConfig, PartitionLayout, RecursivePartial, Theme } from '@elastic/charts'; +import { LabelPositions, PieVisParams, PieContainerDimensions } from '../types'; +const MAX_SIZE = 1000; + +export const getConfig = ( + visParams: PieVisParams, + chartTheme: RecursivePartial, + dimensions?: PieContainerDimensions +): RecursivePartial => { + // On small multiples we want the labels to only appear inside + const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); + const usingMargin = + dimensions && !isSplitChart + ? { + margin: { + top: (1 - Math.min(1, MAX_SIZE / dimensions?.height)) / 2, + bottom: (1 - Math.min(1, MAX_SIZE / dimensions?.height)) / 2, + left: (1 - Math.min(1, MAX_SIZE / dimensions?.width)) / 2, + right: (1 - Math.min(1, MAX_SIZE / dimensions?.width)) / 2, + }, + } + : null; + + const usingOuterSizeRatio = + dimensions && !isSplitChart + ? { + outerSizeRatio: MAX_SIZE / Math.min(dimensions?.width, dimensions?.height), + } + : null; + const config: RecursivePartial = { + partitionLayout: PartitionLayout.sunburst, + fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily, + ...usingOuterSizeRatio, + specialFirstInnermostSector: false, + minFontSize: 10, + maxFontSize: 16, + linkLabel: { + maxCount: 5, + fontSize: 11, + textColor: chartTheme.axes?.axisTitle?.fill, + maxTextLength: visParams.labels.truncate ?? undefined, + }, + sectorLineStroke: chartTheme.lineSeriesStyle?.point?.fill, + sectorLineWidth: 1.5, + circlePadding: 4, + emptySizeRatio: visParams.isDonut ? 0.3 : 0, + ...usingMargin, + }; + if (!visParams.labels.show) { + // Force all labels to be linked, then prevent links from showing + config.linkLabel = { maxCount: 0, maximumSection: Number.POSITIVE_INFINITY }; + } + + if (visParams.labels.last_level && visParams.labels.show) { + config.linkLabel = { + maxCount: Number.POSITIVE_INFINITY, + maximumSection: Number.POSITIVE_INFINITY, + }; + } + + if ( + (visParams.labels.position === LabelPositions.INSIDE || isSplitChart) && + visParams.labels.show + ) { + config.linkLabel = { maxCount: 0 }; + } + return config; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts b/src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts new file mode 100644 index 00000000000000..3d700614a07ed2 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_distinct_series.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { getDistinctSeries } from './get_distinct_series'; +import { createMockVisData, createMockBucketColumns } from '../mocks'; + +const visData = createMockVisData(); +const buckets = createMockBucketColumns(); + +describe('getDistinctSeries', () => { + it('should return the distinct values for all buckets', () => { + const { allSeries } = getDistinctSeries(visData.rows, buckets); + expect(allSeries).toEqual(['Logstash Airways', 'JetBeats', 'ES-Air', 'Kibana Airlines', 0, 1]); + }); + + it('should return only the distinct values for the parent bucket', () => { + const { parentSeries } = getDistinctSeries(visData.rows, buckets); + expect(parentSeries).toEqual(['Logstash Airways', 'JetBeats', 'ES-Air', 'Kibana Airlines']); + }); + + it('should return empty array for empty buckets', () => { + const { parentSeries } = getDistinctSeries(visData.rows, [{ name: 'Count' }]); + expect(parentSeries.length).toEqual(0); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_distinct_series.ts b/src/plugins/vis_type_pie/public/utils/get_distinct_series.ts new file mode 100644 index 00000000000000..ba5042dfc210c5 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_distinct_series.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 { DatatableRow } from '../../../expressions/public'; +import { BucketColumns } from '../types'; + +export const getDistinctSeries = (rows: DatatableRow[], buckets: Array>) => { + const parentBucketId = buckets[0].id; + const parentSeries: string[] = []; + const allSeries: string[] = []; + buckets.forEach(({ id }) => { + if (!id) return; + rows.forEach((row) => { + const name = row[id]; + if (!allSeries.includes(name)) { + allSeries.push(name); + } + if (id === parentBucketId && !parentSeries.includes(row[parentBucketId])) { + parentSeries.push(row[parentBucketId]); + } + }); + }); + return { + allSeries, + parentSeries, + }; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.test.ts b/src/plugins/vis_type_pie/public/utils/get_layers.test.ts new file mode 100644 index 00000000000000..e0658eaa295f95 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_layers.test.ts @@ -0,0 +1,114 @@ +/* + * 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 { ShapeTreeNode } from '@elastic/charts'; +import { PaletteDefinition, SeriesLayer } from '../../../charts/public'; +import { computeColor } from './get_layers'; +import { createMockVisData, createMockBucketColumns, createMockPieParams } from '../mocks'; + +const visData = createMockVisData(); +const buckets = createMockBucketColumns(); +const visParams = createMockPieParams(); +const colors = ['color1', 'color2', 'color3', 'color4']; +export const getPaletteRegistry = () => { + const mockPalette1: jest.Mocked = { + id: 'default', + title: 'My Palette', + getCategoricalColor: jest.fn((layer: SeriesLayer[]) => colors[layer[0].rankAtDepth]), + getCategoricalColors: jest.fn((num: number) => colors), + toExpression: jest.fn(() => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['default'], + }, + }, + ], + })), + }; + + return { + get: () => mockPalette1, + getAll: () => [mockPalette1], + }; +}; + +describe('computeColor', () => { + it('should return the correct color based on the parent sortIndex', () => { + const d = ({ + dataName: 'ES-Air', + depth: 1, + sortIndex: 0, + parent: { + children: [['ES-Air'], ['Kibana Airlines']], + depth: 0, + sortIndex: 0, + }, + } as unknown) as ShapeTreeNode; + const color = computeColor( + d, + false, + {}, + buckets, + visData.rows, + visParams, + getPaletteRegistry(), + false + ); + expect(color).toEqual(colors[0]); + }); + + it('slices with the same label should have the same color for small multiples', () => { + const d = ({ + dataName: 'ES-Air', + depth: 1, + sortIndex: 0, + parent: { + children: [['ES-Air'], ['Kibana Airlines']], + depth: 0, + sortIndex: 0, + }, + } as unknown) as ShapeTreeNode; + const color = computeColor( + d, + true, + {}, + buckets, + visData.rows, + visParams, + getPaletteRegistry(), + false + ); + expect(color).toEqual('color3'); + }); + it('returns the overwriteColor if exists', () => { + const d = ({ + dataName: 'ES-Air', + depth: 1, + sortIndex: 0, + parent: { + children: [['ES-Air'], ['Kibana Airlines']], + depth: 0, + sortIndex: 0, + }, + } as unknown) as ShapeTreeNode; + const color = computeColor( + d, + true, + { 'ES-Air': '#000028' }, + buckets, + visData.rows, + visParams, + getPaletteRegistry(), + false + ); + expect(color).toEqual('#000028'); + }); +}); diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.ts b/src/plugins/vis_type_pie/public/utils/get_layers.ts new file mode 100644 index 00000000000000..27dcf2d379811d --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_layers.ts @@ -0,0 +1,186 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + Datum, + PartitionFillLabel, + PartitionLayer, + ShapeTreeNode, + ArrayEntry, +} from '@elastic/charts'; +import { isEqual } from 'lodash'; +import { SeriesLayer, PaletteRegistry, lightenColor } from '../../../charts/public'; +import { DataPublicPluginStart } from '../../../data/public'; +import { DatatableRow } from '../../../expressions/public'; +import { BucketColumns, PieVisParams, SplitDimensionParams } from '../types'; +import { getDistinctSeries } from './get_distinct_series'; + +const EMPTY_SLICE = Symbol('empty_slice'); + +export const computeColor = ( + d: ShapeTreeNode, + isSplitChart: boolean, + overwriteColors: { [key: string]: string }, + columns: Array>, + rows: DatatableRow[], + visParams: PieVisParams, + palettes: PaletteRegistry | null, + syncColors: boolean +) => { + const { parentSeries, allSeries } = getDistinctSeries(rows, columns); + + if (visParams.distinctColors) { + const dataName = d.dataName; + if (Object.keys(overwriteColors).includes(dataName.toString())) { + return overwriteColors[dataName]; + } + + const index = allSeries.findIndex((name) => isEqual(name, dataName)); + const isSplitParentLayer = isSplitChart && parentSeries.includes(dataName); + return palettes?.get(visParams.palette.name).getCategoricalColor( + [ + { + name: dataName, + rankAtDepth: isSplitParentLayer + ? parentSeries.findIndex((name) => name === dataName) + : index > -1 + ? index + : 0, + totalSeriesAtDepth: isSplitParentLayer ? parentSeries.length : allSeries.length || 1, + }, + ], + { + maxDepth: 1, + totalSeries: allSeries.length || 1, + behindText: visParams.labels.show, + syncColors, + } + ); + } + const seriesLayers: SeriesLayer[] = []; + let tempParent: typeof d | typeof d['parent'] = d; + while (tempParent.parent && tempParent.depth > 0) { + const seriesName = String(tempParent.parent.children[tempParent.sortIndex][0]); + const isSplitParentLayer = isSplitChart && parentSeries.includes(seriesName); + seriesLayers.unshift({ + name: seriesName, + rankAtDepth: isSplitParentLayer + ? parentSeries.findIndex((name) => name === seriesName) + : tempParent.sortIndex, + totalSeriesAtDepth: isSplitParentLayer + ? parentSeries.length + : tempParent.parent.children.length, + }); + tempParent = tempParent.parent; + } + + let overwriteColor; + seriesLayers.forEach((layer) => { + if (Object.keys(overwriteColors).includes(layer.name)) { + overwriteColor = overwriteColors[layer.name]; + } + }); + + if (overwriteColor) { + return lightenColor(overwriteColor, seriesLayers.length, columns.length); + } + return palettes?.get(visParams.palette.name).getCategoricalColor(seriesLayers, { + behindText: visParams.labels.show, + maxDepth: columns.length, + totalSeries: rows.length, + syncColors, + }); +}; + +export const getLayers = ( + columns: Array>, + visParams: PieVisParams, + overwriteColors: { [key: string]: string }, + rows: DatatableRow[], + palettes: PaletteRegistry | null, + formatter: DataPublicPluginStart['fieldFormats'], + syncColors: boolean +): PartitionLayer[] => { + const fillLabel: Partial = { + textInvertible: true, + valueFont: { + fontWeight: 700, + }, + }; + + if (!visParams.labels.values) { + fillLabel.valueFormatter = () => ''; + } + const isSplitChart = Boolean(visParams.dimensions.splitColumn || visParams.dimensions.splitRow); + return columns.map((col) => { + return { + groupByRollup: (d: Datum) => { + return col.id ? d[col.id] : col.name; + }, + showAccessor: (d: Datum) => d !== EMPTY_SLICE, + nodeLabel: (d: unknown) => { + if (d === '') { + return i18n.translate('visTypePie.emptyLabelValue', { + defaultMessage: '(empty)', + }); + } + if (col.format) { + const formattedLabel = formatter.deserialize(col.format).convert(d) ?? ''; + if (visParams.labels.truncate && formattedLabel.length <= visParams.labels.truncate) { + return formattedLabel; + } else { + return `${formattedLabel.slice(0, Number(visParams.labels.truncate))}\u2026`; + } + } + return String(d); + }, + sortPredicate: ([name1, node1]: ArrayEntry, [name2, node2]: ArrayEntry) => { + const params = col.meta?.sourceParams?.params as SplitDimensionParams | undefined; + const sort: string | undefined = params?.orderBy; + // unconditionally put "Other" to the end (as the "Other" slice may be larger than a regular slice, yet should be at the end) + if (name1 === '__other__' && name2 !== '__other__') return 1; + if (name2 === '__other__' && name1 !== '__other__') return -1; + // metric sorting + if (sort !== '_key') { + if (params?.order === 'desc') { + return node2.value - node1.value; + } else { + return node1.value - node2.value; + } + // alphabetical sorting + } else { + if (name1 > name2) { + return params?.order === 'desc' ? -1 : 1; + } + if (name2 > name1) { + return params?.order === 'desc' ? 1 : -1; + } + } + return 0; + }, + fillLabel, + shape: { + fillColor: (d) => { + const outputColor = computeColor( + d, + isSplitChart, + overwriteColors, + columns, + rows, + visParams, + palettes, + syncColors + ); + + return outputColor || 'rgba(0,0,0,0)'; + }, + }, + }; + }); +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx b/src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx new file mode 100644 index 00000000000000..9f1d5e0db4583a --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_legend_actions.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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, { useState, useEffect } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; +import { LegendAction, SeriesIdentifier } from '@elastic/charts'; +import { DataPublicPluginStart } from '../../../data/public'; +import { PieVisParams } from '../types'; +import { ClickTriggerEvent } from '../../../charts/public'; + +export const getLegendActions = ( + canFilter: ( + data: ClickTriggerEvent | null, + actions: DataPublicPluginStart['actions'] + ) => Promise, + getFilterEventData: (series: SeriesIdentifier) => ClickTriggerEvent | null, + onFilter: (data: ClickTriggerEvent, negate?: any) => void, + visParams: PieVisParams, + actions: DataPublicPluginStart['actions'], + formatter: DataPublicPluginStart['fieldFormats'] +): LegendAction => { + return ({ series: [pieSeries] }) => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [isfilterable, setIsfilterable] = useState(true); + const filterData = getFilterEventData(pieSeries); + + useEffect(() => { + (async () => setIsfilterable(await canFilter(filterData, actions)))(); + }, [filterData]); + + if (!isfilterable || !filterData) { + return null; + } + + let formattedTitle = ''; + if (visParams.dimensions.buckets) { + const column = visParams.dimensions.buckets.find( + (bucket) => bucket.accessor === filterData.data.data[0].column + ); + formattedTitle = formatter.deserialize(column?.format).convert(pieSeries.key) ?? ''; + } + + const title = formattedTitle || pieSeries.key; + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 'main', + title: `${title}`, + items: [ + { + name: i18n.translate('visTypePie.legend.filterForValueButtonAriaLabel', { + defaultMessage: 'Filter for value', + }), + 'data-test-subj': `legend-${title}-filterIn`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(filterData); + }, + }, + { + name: i18n.translate('visTypePie.legend.filterOutValueButtonAriaLabel', { + defaultMessage: 'Filter out value', + }), + 'data-test-subj': `legend-${title}-filterOut`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(filterData, true); + }, + }, + ], + }, + ]; + + const Button = ( +
undefined} + onClick={() => setPopoverOpen(!popoverOpen)} + > + +
+ ); + + return ( + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="upLeft" + title={i18n.translate('visTypePie.legend.filterOptionsLegend', { + defaultMessage: '{legendDataLabel}, filter options', + values: { legendDataLabel: title }, + })} + > + + + ); + }; +}; diff --git a/src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts b/src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.ts new file mode 100644 index 00000000000000..e1029b11a7b758 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/get_split_dimension_accessor.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 { AccessorFn } from '@elastic/charts'; +import { FieldFormatsStart } from '../../../data/public'; +import { DatatableColumn } from '../../../expressions/public'; +import { Dimension } from '../types'; + +export const getSplitDimensionAccessor = ( + fieldFormats: FieldFormatsStart, + columns: DatatableColumn[] +) => (splitDimension: Dimension): AccessorFn => { + const formatter = fieldFormats.deserialize(splitDimension.format); + const splitChartColumn = columns[splitDimension.accessor]; + const accessor = splitChartColumn.id; + + const fn: AccessorFn = (d) => { + const v = d[accessor]; + if (v === undefined) { + return; + } + const f = formatter.convert(v); + return f; + }; + + return fn; +}; diff --git a/src/plugins/vis_type_pie/public/utils/index.ts b/src/plugins/vis_type_pie/public/utils/index.ts new file mode 100644 index 00000000000000..0cf4292ad565a9 --- /dev/null +++ b/src/plugins/vis_type_pie/public/utils/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { getLayers } from './get_layers'; +export { getColorPicker } from './get_color_picker'; +export { getLegendActions } from './get_legend_actions'; +export { canFilter, getFilterClickData, getFilterEventData } from './filter_helpers'; +export { getConfig } from './get_config'; +export { getColumns } from './get_columns'; +export { getSplitDimensionAccessor } from './get_split_dimension_accessor'; +export { getDistinctSeries } from './get_distinct_series'; diff --git a/src/plugins/vis_type_pie/public/vis_type/index.ts b/src/plugins/vis_type_pie/public/vis_type/index.ts new file mode 100644 index 00000000000000..e02e802028a352 --- /dev/null +++ b/src/plugins/vis_type_pie/public/vis_type/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { getPieVisTypeDefinition } from './pie'; +import type { PieTypeProps } from '../types'; + +export const pieVisType = (props: PieTypeProps) => { + return getPieVisTypeDefinition(props); +}; diff --git a/src/plugins/vis_type_pie/public/vis_type/pie.ts b/src/plugins/vis_type_pie/public/vis_type/pie.ts new file mode 100644 index 00000000000000..9d1556ac33ad7e --- /dev/null +++ b/src/plugins/vis_type_pie/public/vis_type/pie.ts @@ -0,0 +1,98 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; +import { AggGroupNames } from '../../../data/public'; +import { VIS_EVENT_TO_TRIGGER, VisTypeDefinition } from '../../../visualizations/public'; +import { DEFAULT_PERCENT_DECIMALS } from '../../common'; +import { PieVisParams, LabelPositions, ValueFormats, PieTypeProps } from '../types'; +import { toExpressionAst } from '../to_ast'; +import { getPieOptions } from '../editor/components'; + +export const getPieVisTypeDefinition = ({ + showElasticChartsOptions = false, + palettes, + trackUiMetric, +}: PieTypeProps): VisTypeDefinition => ({ + name: 'pie', + title: i18n.translate('visTypePie.pie.pieTitle', { defaultMessage: 'Pie' }), + icon: 'visPie', + description: i18n.translate('visTypePie.pie.pieDescription', { + defaultMessage: 'Compare data in proportion to a whole.', + }), + toExpressionAst, + getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], + visConfig: { + defaults: { + type: 'pie', + addTooltip: true, + addLegend: !showElasticChartsOptions, + legendPosition: Position.Right, + nestedLegend: false, + distinctColors: false, + isDonut: true, + palette: { + type: 'palette', + name: 'default', + }, + labels: { + show: true, + last_level: !showElasticChartsOptions, + values: true, + valuesFormat: ValueFormats.PERCENT, + percentDecimals: DEFAULT_PERCENT_DECIMALS, + truncate: 100, + position: LabelPositions.DEFAULT, + }, + }, + }, + editorConfig: { + optionsTemplate: getPieOptions({ + showElasticChartsOptions, + palettes, + trackUiMetric, + }), + schemas: [ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypePie.pie.metricTitle', { + defaultMessage: 'Slice size', + }), + min: 1, + max: 1, + aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], + defaults: [{ schema: 'metric', type: 'count' }], + }, + { + group: AggGroupNames.Buckets, + name: 'segment', + title: i18n.translate('visTypePie.pie.segmentTitle', { + defaultMessage: 'Split slices', + }), + min: 0, + max: Infinity, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + }, + { + group: AggGroupNames.Buckets, + name: 'split', + title: i18n.translate('visTypePie.pie.splitTitle', { + defaultMessage: 'Split chart', + }), + mustBeFirst: true, + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + }, + ], + }, + hierarchicalData: true, + requiresSearch: true, +}); diff --git a/src/plugins/vis_type_pie/tsconfig.json b/src/plugins/vis_type_pie/tsconfig.json new file mode 100644 index 00000000000000..f12db316f19723 --- /dev/null +++ b/src/plugins/vis_type_pie/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*" + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../charts/tsconfig.json" }, + { "path": "../data/tsconfig.json" }, + { "path": "../expressions/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, + { "path": "../usage_collection/tsconfig.json" }, + { "path": "../vis_default_editor/tsconfig.json" }, + ] + } \ No newline at end of file diff --git a/src/plugins/vis_type_vislib/kibana.json b/src/plugins/vis_type_vislib/kibana.json index 175c21f47c182a..56dfba0aca59c0 100644 --- a/src/plugins/vis_type_vislib/kibana.json +++ b/src/plugins/vis_type_vislib/kibana.json @@ -4,5 +4,5 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "kibanaLegacy"], - "requiredBundles": ["kibanaUtils", "visDefaultEditor", "visTypeXy"] + "requiredBundles": ["kibanaUtils", "visDefaultEditor", "visTypeXy", "visTypePie"] } diff --git a/src/plugins/vis_type_vislib/public/editor/components/index.tsx b/src/plugins/vis_type_vislib/public/editor/components/index.tsx index a90aaeab58503d..34547dc7115e28 100644 --- a/src/plugins/vis_type_vislib/public/editor/components/index.tsx +++ b/src/plugins/vis_type_vislib/public/editor/components/index.tsx @@ -10,21 +10,15 @@ import React, { lazy } from 'react'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; import { GaugeVisParams } from '../../gauge'; -import { PieVisParams } from '../../pie'; import { HeatmapVisParams } from '../../heatmap'; const GaugeOptionsLazy = lazy(() => import('./gauge')); -const PieOptionsLazy = lazy(() => import('./pie')); const HeatmapOptionsLazy = lazy(() => import('./heatmap')); export const GaugeOptions = (props: VisEditorOptionsProps) => ( ); -export const PieOptions = (props: VisEditorOptionsProps) => ( - -); - export const HeatmapOptions = (props: VisEditorOptionsProps) => ( ); diff --git a/src/plugins/vis_type_vislib/public/editor/components/pie.tsx b/src/plugins/vis_type_vislib/public/editor/components/pie.tsx deleted file mode 100644 index 6c84bc744676a9..00000000000000 --- a/src/plugins/vis_type_vislib/public/editor/components/pie.tsx +++ /dev/null @@ -1,97 +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 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 { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { BasicOptions, SwitchOption } from '../../../../vis_default_editor/public'; -import { TruncateLabelsOption, getPositions } from '../../../../vis_type_xy/public'; - -import { PieVisParams } from '../../pie'; - -const legendPositions = getPositions(); - -function PieOptions(props: VisEditorOptionsProps) { - const { stateParams, setValue } = props; - const setLabels = ( - paramName: T, - value: PieVisParams['labels'][T] - ) => setValue('labels', { ...stateParams.labels, [paramName]: value }); - - return ( - <> - - -

- -

-
- - - -
- - - - - -

- -

-
- - - - - -
- - ); -} - -// default export required for React.Lazy -// eslint-disable-next-line import/no-default-export -export { PieOptions as default }; diff --git a/src/plugins/vis_type_vislib/public/pie.ts b/src/plugins/vis_type_vislib/public/pie.ts index d1d8d2a5279feb..4f6eb7e5365092 100644 --- a/src/plugins/vis_type_vislib/public/pie.ts +++ b/src/plugins/vis_type_vislib/public/pie.ts @@ -6,14 +6,9 @@ * Side Public License, v 1. */ -import { i18n } from '@kbn/i18n'; -import { Position } from '@elastic/charts'; - -import { AggGroupNames } from '../../data/public'; -import { VisTypeDefinition, VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; - +import { pieVisType } from '../../vis_type_pie/public'; +import { VisTypeDefinition } from '../../visualizations/public'; import { CommonVislibParams } from './types'; -import { PieOptions } from './editor'; import { toExpressionAst } from './to_ast_pie'; export interface PieVisParams extends CommonVislibParams { @@ -27,67 +22,7 @@ export interface PieVisParams extends CommonVislibParams { }; } -export const pieVisTypeDefinition: VisTypeDefinition = { - name: 'pie', - title: i18n.translate('visTypeVislib.pie.pieTitle', { defaultMessage: 'Pie' }), - icon: 'visPie', - description: i18n.translate('visTypeVislib.pie.pieDescription', { - defaultMessage: 'Compare data in proportion to a whole.', - }), - getSupportedTriggers: () => [VIS_EVENT_TO_TRIGGER.filter], +export const pieVisTypeDefinition = { + ...pieVisType({}), toExpressionAst, - visConfig: { - defaults: { - type: 'pie', - addTooltip: true, - addLegend: true, - legendPosition: Position.Right, - isDonut: true, - labels: { - show: false, - values: true, - last_level: true, - truncate: 100, - }, - }, - }, - editorConfig: { - optionsTemplate: PieOptions, - schemas: [ - { - group: AggGroupNames.Metrics, - name: 'metric', - title: i18n.translate('visTypeVislib.pie.metricTitle', { - defaultMessage: 'Slice size', - }), - min: 1, - max: 1, - aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], - defaults: [{ schema: 'metric', type: 'count' }], - }, - { - group: AggGroupNames.Buckets, - name: 'segment', - title: i18n.translate('visTypeVislib.pie.segmentTitle', { - defaultMessage: 'Split slices', - }), - min: 0, - max: Infinity, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - }, - { - group: AggGroupNames.Buckets, - name: 'split', - title: i18n.translate('visTypeVislib.pie.splitTitle', { - defaultMessage: 'Split chart', - }), - mustBeFirst: true, - min: 0, - max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - }, - ], - }, - hierarchicalData: true, - requiresSearch: true, -}; +} as VisTypeDefinition; diff --git a/src/plugins/vis_type_vislib/public/plugin.ts b/src/plugins/vis_type_vislib/public/plugin.ts index 9d329c92bede0c..52faf8a74778c3 100644 --- a/src/plugins/vis_type_vislib/public/plugin.ts +++ b/src/plugins/vis_type_vislib/public/plugin.ts @@ -13,7 +13,7 @@ import { VisualizationsSetup } from '../../visualizations/public'; import { ChartsPluginSetup } from '../../charts/public'; import { DataPublicPluginStart } from '../../data/public'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; -import { LEGACY_CHARTS_LIBRARY } from '../../vis_type_xy/public'; +import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; import { createVisTypeVislibVisFn } from './vis_type_vislib_vis_fn'; import { createPieVisFn } from './pie_fn'; @@ -53,9 +53,8 @@ export class VisTypeVislibPlugin if (!core.uiSettings.get(LEGACY_CHARTS_LIBRARY, false)) { // Register only non-replaced vis types convertedTypeDefinitions.forEach(visualizations.createBaseVisualization); - visualizations.createBaseVisualization(pieVisTypeDefinition); expressions.registerRenderer(getVislibVisRenderer(core, charts)); - [createVisTypeVislibVisFn(), createPieVisFn()].forEach(expressions.registerFunction); + expressions.registerFunction(createVisTypeVislibVisFn()); } else { // Register all vis types visLibVisTypeDefinitions.forEach(visualizations.createBaseVisualization); diff --git a/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts b/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts index 3ca52f27e3fa18..3178c23ee8fa0d 100644 --- a/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts +++ b/src/plugins/vis_type_vislib/public/to_ast_pie.test.ts @@ -10,7 +10,7 @@ import { Vis } from '../../visualizations/public'; import { buildExpression } from '../../expressions/public'; import { PieVisParams } from './pie'; -import { samplePieVis } from '../../vis_type_xy/public/sample_vis.test.mocks'; +import { samplePieVis } from '../../vis_type_pie/public/sample_vis.test.mocks'; import { toExpressionAst } from './to_ast_pie'; jest.mock('../../expressions/public', () => ({ diff --git a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts index 71f692b80b531f..de91053b6dc4df 100644 --- a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts +++ b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.test.ts @@ -5,8 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -import { buildHierarchicalData, Dimensions, Dimension } from './build_hierarchical_data'; +import type { Dimensions, Dimension } from '../../../../../vis_type_pie/public'; +import { buildHierarchicalData } from './build_hierarchical_data'; import { Table, TableParent } from '../../types'; function tableVisResponseHandler(table: Table, dimensions: Dimensions) { diff --git a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts index b235d3936ae0fd..da10edf9591fbf 100644 --- a/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts +++ b/src/plugins/vis_type_vislib/public/vislib/helpers/hierarchical/build_hierarchical_data.ts @@ -7,24 +7,9 @@ */ import { toArray } from 'lodash'; -import { SerializedFieldFormat } from '../../../../../expressions/common/types'; import { getFormatService } from '../../../services'; import { Table } from '../../types'; - -export interface Dimension { - accessor: number; - format: { - id?: string; - params?: SerializedFieldFormat; - }; -} - -export interface Dimensions { - metric: Dimension; - buckets?: Dimension[]; - splitRow?: Dimension[]; - splitColumn?: Dimension[]; -} +import type { Dimensions } from '../../../../../vis_type_pie/public'; interface Slice { name: string; diff --git a/src/plugins/vis_type_vislib/tsconfig.json b/src/plugins/vis_type_vislib/tsconfig.json index 74bc1440d9dbc6..5bf1af9ba75fea 100644 --- a/src/plugins/vis_type_vislib/tsconfig.json +++ b/src/plugins/vis_type_vislib/tsconfig.json @@ -22,5 +22,6 @@ { "path": "../kibana_utils/tsconfig.json" }, { "path": "../vis_default_editor/tsconfig.json" }, { "path": "../vis_type_xy/tsconfig.json" }, + { "path": "../vis_type_pie/tsconfig.json" }, ] } diff --git a/src/plugins/vis_type_xy/common/index.ts b/src/plugins/vis_type_xy/common/index.ts index a80946f7c62fa3..f17bc8476d9a68 100644 --- a/src/plugins/vis_type_xy/common/index.ts +++ b/src/plugins/vis_type_xy/common/index.ts @@ -19,5 +19,3 @@ export enum ChartType { * Type of xy visualizations */ export type XyVisType = ChartType | 'horizontal_bar'; - -export const LEGACY_CHARTS_LIBRARY = 'visualization:visualize:legacyChartsLibrary'; diff --git a/src/plugins/vis_type_xy/kibana.json b/src/plugins/vis_type_xy/kibana.json index 619fa8e71c0dde..a32b1e4d1d8b51 100644 --- a/src/plugins/vis_type_xy/kibana.json +++ b/src/plugins/vis_type_xy/kibana.json @@ -1,7 +1,6 @@ { "id": "visTypeXy", "version": "kibana", - "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection"], "requiredBundles": ["kibanaUtils", "visDefaultEditor"] diff --git a/src/plugins/vis_type_xy/public/plugin.ts b/src/plugins/vis_type_xy/public/plugin.ts index 7bdb4f78bc631d..e8d53127765b4c 100644 --- a/src/plugins/vis_type_xy/public/plugin.ts +++ b/src/plugins/vis_type_xy/public/plugin.ts @@ -23,7 +23,7 @@ import { } from './services'; import { visTypesDefinitions } from './vis_types'; -import { LEGACY_CHARTS_LIBRARY } from '../common'; +import { LEGACY_CHARTS_LIBRARY } from '../../visualizations/common/constants'; import { xyVisRenderer } from './vis_renderer'; import * as expressionFunctions from './expression_functions'; diff --git a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts index 39370d941b52ac..8fafd4c7230557 100644 --- a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts @@ -5,1325 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -export const samplePieVis = { - type: { - name: 'pie', - title: 'Pie', - description: 'Compare parts of a whole', - icon: 'visPie', - stage: 'production', - options: { - showTimePicker: true, - showQueryBar: true, - showFilterBar: true, - showIndexSelection: true, - hierarchicalData: false, - }, - visConfig: { - defaults: { - type: 'pie', - addTooltip: true, - addLegend: true, - legendPosition: 'right', - isDonut: true, - labels: { - show: false, - values: true, - last_level: true, - truncate: 100, - }, - }, - }, - editorConfig: { - collections: { - legendPositions: [ - { - text: 'Top', - value: 'top', - }, - { - text: 'Left', - value: 'left', - }, - { - text: 'Right', - value: 'right', - }, - { - text: 'Bottom', - value: 'bottom', - }, - ], - }, - schemas: { - all: [ - { - group: 'metrics', - name: 'metric', - title: 'Slice size', - min: 1, - max: 1, - aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], - defaults: [ - { - schema: 'metric', - type: 'count', - }, - ], - editor: false, - params: [], - }, - { - group: 'buckets', - name: 'segment', - title: 'Split slices', - min: 0, - max: null, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - editor: false, - params: [], - }, - { - group: 'buckets', - name: 'split', - title: 'Split chart', - mustBeFirst: true, - min: 0, - max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], - params: [ - { - name: 'row', - default: true, - }, - ], - editor: false, - }, - ], - buckets: [null, null], - metrics: [null], - }, - }, - hidden: false, - hierarchicalData: true, - }, - title: '[Flights] Airline Carrier', - description: '', - params: { - type: 'pie', - addTooltip: true, - addLegend: true, - legendPosition: 'right', - isDonut: true, - labels: { - show: true, - values: true, - last_level: true, - truncate: 100, - }, - }, - data: { - searchSource: { - id: 'data_source1', - requestStartHandlers: [], - inheritOptions: {}, - history: [], - fields: { - filter: [], - query: { - query: '', - language: 'kuery', - }, - index: { - id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', - title: 'kibana_sample_data_flights', - fieldFormatMap: { - AvgTicketPrice: { - id: 'number', - params: { - parsedUrl: { - origin: 'http://localhost:5801', - pathname: '/app/visualize', - basePath: '', - }, - pattern: '$0,0.[00]', - }, - }, - hour_of_day: { - id: 'number', - params: { - pattern: '00', - }, - }, - }, - fields: [ - { - count: 0, - name: 'AvgTicketPrice', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Cancelled', - type: 'boolean', - esTypes: ['boolean'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Carrier', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Dest', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestAirportID', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestCityName', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestCountry', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestLocation', - type: 'geo_point', - esTypes: ['geo_point'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestRegion', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DestWeather', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DistanceKilometers', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'DistanceMiles', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightDelay', - type: 'boolean', - esTypes: ['boolean'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightDelayMin', - type: 'number', - esTypes: ['integer'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightDelayType', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightNum', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightTimeHour', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'FlightTimeMin', - type: 'number', - esTypes: ['float'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'Origin', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginAirportID', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginCityName', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginCountry', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginLocation', - type: 'geo_point', - esTypes: ['geo_point'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginRegion', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'OriginWeather', - type: 'string', - esTypes: ['keyword'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: '_id', - type: 'string', - esTypes: ['_id'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - { - count: 0, - name: '_index', - type: 'string', - esTypes: ['_index'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - { - count: 0, - name: '_score', - type: 'number', - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: false, - }, - { - count: 0, - name: '_source', - type: '_source', - esTypes: ['_source'], - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: false, - }, - { - count: 0, - name: '_type', - type: 'string', - esTypes: ['_type'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - { - count: 0, - name: 'dayOfWeek', - type: 'number', - esTypes: ['integer'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - name: 'timestamp', - type: 'date', - esTypes: ['date'], - scripted: false, - searchable: true, - aggregatable: true, - readFromDocValues: true, - }, - { - count: 0, - script: "doc['timestamp'].value.hourOfDay", - lang: 'painless', - name: 'hour_of_day', - type: 'number', - scripted: true, - searchable: true, - aggregatable: true, - readFromDocValues: false, - }, - ], - timeFieldName: 'timestamp', - metaFields: ['_source', '_id', '_type', '_index', '_score'], - version: 'WzM1LDFd', - originalSavedObjectBody: { - title: 'kibana_sample_data_flights', - timeFieldName: 'timestamp', - fields: - '[{"count":0,"name":"AvgTicketPrice","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Cancelled","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Carrier","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Dest","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DestWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceKilometers","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"DistanceMiles","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelay","type":"boolean","esTypes":["boolean"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayMin","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightDelayType","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightNum","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeHour","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"FlightTimeMin","type":"number","esTypes":["float"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"Origin","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginAirportID","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCityName","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginCountry","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginLocation","type":"geo_point","esTypes":["geo_point"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginRegion","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"OriginWeather","type":"string","esTypes":["keyword"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"_id","type":"string","esTypes":["_id"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_index","type":"string","esTypes":["_index"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"_score","type":"number","scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_source","type":"_source","esTypes":["_source"],"scripted":false,"searchable":false,"aggregatable":false,"readFromDocValues":false},{"count":0,"name":"_type","type":"string","esTypes":["_type"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":false},{"count":0,"name":"dayOfWeek","type":"number","esTypes":["integer"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"name":"timestamp","type":"date","esTypes":["date"],"scripted":false,"searchable":true,"aggregatable":true,"readFromDocValues":true},{"count":0,"script":"doc[\'timestamp\'].value.hourOfDay","lang":"painless","name":"hour_of_day","type":"number","scripted":true,"searchable":true,"aggregatable":true,"readFromDocValues":false}]', - fieldFormatMap: - '{"AvgTicketPrice":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"$0,0.[00]"}},"hour_of_day":{"id":"number","params":{"parsedUrl":{"origin":"http://localhost:5801","pathname":"/app/visualize","basePath":""},"pattern":"00"}}}', - }, - shortDotsEnable: false, - fieldFormats: { - fieldFormats: {}, - defaultMap: { - ip: { - id: 'ip', - params: {}, - }, - date: { - id: 'date', - params: {}, - }, - date_nanos: { - id: 'date_nanos', - params: {}, - es: true, - }, - number: { - id: 'number', - params: {}, - }, - boolean: { - id: 'boolean', - params: {}, - }, - _source: { - id: '_source', - params: {}, - }, - _default_: { - id: 'string', - params: {}, - }, - }, - metaParamsOptions: {}, - }, - }, - }, - dependencies: { - legacy: { - loadingCount$: { - _isScalar: false, - observers: [ - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - destination: { - closed: true, - }, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 1, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [ - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 13, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 1, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 1, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: { - closed: false, - _parentOrParents: null, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - destination: { - closed: false, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - _context: {}, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - count: 3, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: false, - hasPrev: true, - prev: 0, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: true, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [], - active: 1, - index: 2, - }, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - parent: { - closed: true, - _parentOrParents: null, - _subscriptions: null, - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: true, - concurrent: 1, - hasCompleted: true, - buffer: [ - { - _isScalar: false, - }, - ], - active: 1, - index: 1, - }, - }, - _subscriptions: [ - null, - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - subject: { - _isScalar: false, - observers: [null], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - null, - ], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - }, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - null, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - seenValue: false, - }, - _subscriptions: [null], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - }, - _subscriptions: [ - { - closed: false, - _subscriptions: null, - }, - ], - syncErrorValue: null, - syncErrorThrown: false, - syncErrorThrowable: false, - isStopped: false, - hasKey: true, - key: 0, - }, - ], - closed: false, - isStopped: false, - hasError: false, - thrownError: null, - _value: 0, - }, - }, - }, - }, - aggs: { - typesRegistry: {}, - getResponseAggs: () => [ - { - id: '1', - enabled: true, - type: 'count', - params: {}, - schema: 'metric', - toSerializedFieldFormat: () => ({ - id: 'number', - }), - }, - { - id: '2', - enabled: true, - type: 'terms', - params: { - field: 'Carrier', - orderBy: '1', - order: 'desc', - size: 5, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, - schema: 'segment', - toSerializedFieldFormat: () => ({ - id: 'terms', - params: { - id: 'string', - otherBucketLabel: 'Other', - missingBucketLabel: 'Missing', - parsedUrl: { - origin: 'http://localhost:5801', - pathname: '/app/visualize', - basePath: '', - }, - }, - }), - }, - ], - }, - }, - isHierarchical: () => true, - uiState: { - vis: { - legendOpen: false, - }, - }, -}; - export const sampleAreaVis = { type: { name: 'area', diff --git a/src/plugins/vis_type_xy/server/plugin.ts b/src/plugins/vis_type_xy/server/plugin.ts deleted file mode 100644 index 08aefdeb836b0e..00000000000000 --- a/src/plugins/vis_type_xy/server/plugin.ts +++ /dev/null @@ -1,46 +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 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 { i18n } from '@kbn/i18n'; -import { schema } from '@kbn/config-schema'; - -import { CoreSetup, Plugin, UiSettingsParams } from 'kibana/server'; - -import { LEGACY_CHARTS_LIBRARY } from '../common'; - -export const getUiSettingsConfig: () => Record> = () => ({ - // TODO: Remove this when vis_type_vislib is removed - // https://github.com/elastic/kibana/issues/56143 - [LEGACY_CHARTS_LIBRARY]: { - name: i18n.translate('visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name', { - defaultMessage: 'Legacy charts library', - }), - requiresPageReload: true, - value: false, - description: i18n.translate( - 'visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description', - { - defaultMessage: 'Enables legacy charts library for area, line and bar charts in visualize.', - } - ), - category: ['visualization'], - schema: schema.boolean(), - }, -}); - -export class VisTypeXyServerPlugin implements Plugin { - public setup(core: CoreSetup) { - core.uiSettings.register(getUiSettingsConfig()); - - return {}; - } - - public start() { - return {}; - } -} diff --git a/src/plugins/visualizations/common/constants.ts b/src/plugins/visualizations/common/constants.ts index a8a0963ac89480..a33e74b498a2ce 100644 --- a/src/plugins/visualizations/common/constants.ts +++ b/src/plugins/visualizations/common/constants.ts @@ -7,3 +7,4 @@ */ export const VISUALIZE_ENABLE_LABS_SETTING = 'visualize:enableLabs'; +export const LEGACY_CHARTS_LIBRARY = 'visualization:visualize:legacyChartsLibrary'; diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index 0ced74e2733d31..939b331414166c 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -12,5 +12,6 @@ "savedObjects" ], "optionalPlugins": ["usageCollection"], - "requiredBundles": ["kibanaUtils", "discover"] + "requiredBundles": ["kibanaUtils", "discover"], + "extraPublicDirs": ["common/constants"] } diff --git a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts index 212c033a65c263..edfd05b84dfc8e 100644 --- a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts +++ b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts @@ -13,6 +13,7 @@ import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel, + commonMigrateVislibPie, commonAddEmptyValueColorRule, } from '../migrations/visualization_common_migrations'; @@ -44,6 +45,13 @@ const byValueAddEmptyValueColorRule = (state: SerializableState) => { }; }; +const byValueMigrateVislibPie = (state: SerializableState) => { + return { + ...state, + savedVis: commonMigrateVislibPie(state.savedVis), + }; +}; + export const visualizeEmbeddableFactory = (): EmbeddableRegistryDefinition => { return { id: 'visualization', @@ -55,7 +63,7 @@ export const visualizeEmbeddableFactory = (): EmbeddableRegistryDefinition => { byValueHideTSVBLastValueIndicator, byValueRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel )(state), - '7.14.0': (state) => flow(byValueAddEmptyValueColorRule)(state), + '7.14.0': (state) => flow(byValueAddEmptyValueColorRule, byValueMigrateVislibPie)(state), }, }; }; diff --git a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts index 13b8d8c4a0f982..f5afeee0ff35ea 100644 --- a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts @@ -91,3 +91,26 @@ export const commonAddEmptyValueColorRule = (visState: any) => { return visState; }; + +export const commonMigrateVislibPie = (visState: any) => { + if (visState && visState.type === 'pie') { + const { params } = visState; + const hasPalette = params?.palette; + + return { + ...visState, + params: { + ...visState.params, + ...(!hasPalette && { + palette: { + type: 'palette', + name: 'kibana_palette', + }, + }), + distinctColors: true, + }, + }; + } + + return visState; +}; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index 36e1635ad4730e..7ee43f36c864e2 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -2114,4 +2114,52 @@ describe('migration visualization', () => { checkRuleIsNotAddedToArray('gauge_color_rules', params, migratedParams, rule4); }); }); + + describe('7.14.0 update pie visualization defaults', () => { + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['7.14.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + const getTestDoc = (hasPalette = false) => ({ + attributes: { + title: 'My Vis', + description: 'This is my super cool vis.', + visState: JSON.stringify({ + type: 'pie', + title: '[Flights] Delay Type', + params: { + type: 'pie', + ...(hasPalette && { + palette: { + type: 'palette', + name: 'default', + }, + }), + }, + }), + }, + }); + + it('should decorate existing docs with the kibana legacy palette if the palette is not defined - pie', () => { + const migratedTestDoc = migrate(getTestDoc()); + const { palette } = JSON.parse(migratedTestDoc.attributes.visState).params; + + expect(palette.name).toEqual('kibana_palette'); + }); + + it('should not overwrite the palette with the legacy one if the palette already exists in the saved object', () => { + const migratedTestDoc = migrate(getTestDoc(true)); + const { palette } = JSON.parse(migratedTestDoc.attributes.visState).params; + + expect(palette.name).toEqual('default'); + }); + + it('should default the distinct colors per slice setting to true', () => { + const migratedTestDoc = migrate(getTestDoc()); + const { distinctColors } = JSON.parse(migratedTestDoc.attributes.visState).params; + + expect(distinctColors).toBe(true); + }); + }); }); diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index c5050b4a6940b7..f386d9eb12091e 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -15,6 +15,7 @@ import { commonAddSupportOfDualIndexSelectionModeInTSVB, commonHideTSVBLastValueIndicator, commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel, + commonMigrateVislibPie, commonAddEmptyValueColorRule, } from './visualization_common_migrations'; @@ -990,6 +991,29 @@ const addEmptyValueColorRule: SavedObjectMigrationFn = (doc) => { return doc; }; +// [Pie Chart] Migrate vislib pie chart to use the new plugin vis_type_pie +const migrateVislibPie: SavedObjectMigrationFn = (doc) => { + const visStateJSON = get(doc, 'attributes.visState'); + let visState; + + if (visStateJSON) { + try { + visState = JSON.parse(visStateJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + const newVisState = commonMigrateVislibPie(visState); + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; + } + return doc; +}; + export const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -1036,5 +1060,5 @@ export const visualizationSavedObjectTypeMigrations = { hideTSVBLastValueIndicator, removeDefaultIndexPatternAndTimeFieldFromTSVBModel ), - '7.14.0': flow(addEmptyValueColorRule), + '7.14.0': flow(addEmptyValueColorRule, migrateVislibPie), }; diff --git a/src/plugins/visualizations/server/plugin.ts b/src/plugins/visualizations/server/plugin.ts index 5a5a80b2689d6e..1fec63f2bb45ad 100644 --- a/src/plugins/visualizations/server/plugin.ts +++ b/src/plugins/visualizations/server/plugin.ts @@ -18,7 +18,7 @@ import { Logger, } from '../../../core/server'; -import { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; +import { VISUALIZE_ENABLE_LABS_SETTING, LEGACY_CHARTS_LIBRARY } from '../common/constants'; import { visualizationSavedObjectType } from './saved_objects'; @@ -58,6 +58,27 @@ export class VisualizationsPlugin category: ['visualization'], schema: schema.boolean(), }, + // TODO: Remove this when vis_type_vislib is removed + // https://github.com/elastic/kibana/issues/56143 + [LEGACY_CHARTS_LIBRARY]: { + name: i18n.translate( + 'visualizations.advancedSettings.visualization.legacyChartsLibrary.name', + { + defaultMessage: 'Legacy charts library', + } + ), + requiresPageReload: true, + value: false, + description: i18n.translate( + 'visualizations.advancedSettings.visualization.legacyChartsLibrary.description', + { + defaultMessage: + 'Enables legacy charts library for area, line, bar, pie charts in visualize.', + } + ), + category: ['visualization'], + schema: schema.boolean(), + }, }); if (plugins.usageCollection) { diff --git a/test/examples/embeddables/dashboard.ts b/test/examples/embeddables/dashboard.ts index 597846ab6a43d4..69788ebad2af2f 100644 --- a/test/examples/embeddables/dashboard.ts +++ b/test/examples/embeddables/dashboard.ts @@ -97,7 +97,8 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const pieChart = getService('pieChart'); const browser = getService('browser'); const dashboardExpect = getService('dashboardExpect'); - const PageObjects = getPageObjects(['common']); + const elasticChart = getService('elasticChart'); + const PageObjects = getPageObjects(['common', 'visChart']); describe('dashboard container', () => { before(async () => { @@ -109,6 +110,9 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide }); it('pie charts', async () => { + if (await PageObjects.visChart.isNewChartsLibraryEnabled()) { + await elasticChart.setNewChartUiDebugFlag(); + } await pieChart.expectPieSliceCount(5); }); diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts index acb2bd869819d4..0f7722925293b1 100644 --- a/test/functional/apps/dashboard/dashboard_state.ts +++ b/test/functional/apps/dashboard/dashboard_state.ts @@ -256,8 +256,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('for embeddable config color parameters on a visualization', () => { + let originalPieSliceStyle = ''; it('updates a pie slice color on a soft refresh', async function () { await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); + + originalPieSliceStyle = await pieChart.getPieSliceStyle(`80,000`); await PageObjects.visChart.openLegendOptionColors( '80,000', `[data-title="${PIE_CHART_VIS_NAME}"]` @@ -272,7 +275,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const allPieSlicesColor = await pieChart.getAllPieSliceStyles('80,000'); let whitePieSliceCounts = 0; allPieSlicesColor.forEach((style) => { - if (style.indexOf('rgb(255, 255, 255)') > 0) { + if (style.indexOf('rgb(255, 255, 255)') > -1) { whitePieSliceCounts++; } }); @@ -290,14 +293,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('resets a pie slice color to the original when removed', async function () { const currentUrl = await getUrlFromShare(); - const newUrl = currentUrl.replace(`vis:(colors:('80,000':%23FFFFFF))`, ''); + const newUrl = isNewChartsLibraryEnabled + ? currentUrl.replace(`'80000':%23FFFFFF`, '') + : currentUrl.replace(`vis:(colors:('80,000':%23FFFFFF))`, ''); await browser.get(newUrl.toString(), false); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { - const pieSliceStyle = await pieChart.getPieSliceStyle(`80,000`); - // The default green color that was stored with the visualization before any dashboard overrides. - expect(pieSliceStyle.indexOf('rgb(87, 193, 123)')).to.be.greaterThan(0); + const pieSliceStyle = await pieChart.getPieSliceStyle('80,000'); + + // After removing all overrides, pie slice style should match original. + expect(pieSliceStyle).to.be(originalPieSliceStyle); }); }); diff --git a/test/functional/apps/visualize/_pie_chart.ts b/test/functional/apps/visualize/_pie_chart.ts index dd58ca6514c362..8f76e2765e42c9 100644 --- a/test/functional/apps/visualize/_pie_chart.ts +++ b/test/functional/apps/visualize/_pie_chart.ts @@ -15,6 +15,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const filterBar = getService('filterBar'); const pieChart = getService('pieChart'); const inspector = getService('inspector'); + const browser = getService('browser'); + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects([ 'common', 'visualize', @@ -25,9 +28,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ]); describe('pie chart', function () { + // Used to track flag before and after reset + let isNewChartsLibraryEnabled = false; const vizName1 = 'Visualization PieChart'; before(async function () { + isNewChartsLibraryEnabled = await PageObjects.visChart.isNewChartsLibraryEnabled(); await PageObjects.visualize.initTests(); + if (isNewChartsLibraryEnabled) { + await kibanaServer.uiSettings.update({ + 'visualization:visualize:legacyChartsLibrary': false, + }); + await browser.refresh(); + } log.debug('navigateToApp visualize'); await PageObjects.visualize.navigateToNewAggBasedVisualization(); log.debug('clickPieChart'); @@ -84,7 +96,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('other bucket', () => { it('should show other and missing bucket', async function () { - const expectedTableData = ['win 8', 'win xp', 'win 7', 'ios', 'Missing', 'Other']; + const expectedTableData = ['Missing', 'Other', 'ios', 'win 7', 'win 8', 'win xp']; await PageObjects.visualize.navigateToNewAggBasedVisualization(); log.debug('clickPieChart'); @@ -168,7 +180,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'ID', 'BR', 'Other', - ]; + ].sort(); await PageObjects.visEditor.toggleOpenEditor(2, 'false'); await PageObjects.visEditor.clickBucket('Split slices'); @@ -190,7 +202,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show correct result with one agg disabled', async () => { - const expectedTableData = ['win 8', 'win xp', 'win 7', 'ios', 'osx']; + const expectedTableData = ['ios', 'osx', 'win 7', 'win 8', 'win xp']; await PageObjects.visEditor.clickBucket('Split slices'); await PageObjects.visEditor.selectAggregation('Terms'); @@ -207,7 +219,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualize.loadSavedVisualization(vizName1); await PageObjects.visChart.waitForRenderingCount(); - const expectedTableData = ['win 8', 'win xp', 'win 7', 'ios', 'osx']; + const expectedTableData = ['ios', 'osx', 'win 7', 'win 8', 'win xp']; await pieChart.expectPieChartLabels(expectedTableData); }); @@ -276,7 +288,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'ios', 'win 8', 'osx', - ]; + ].sort(); await pieChart.expectPieChartLabels(expectedTableData); }); @@ -426,7 +438,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'CN', '360,000', 'CN', - ]; + ].sort(); + if (await PageObjects.visChart.isNewLibraryChart('visTypePieChart')) { + await PageObjects.visEditor.clickOptionsTab(); + await PageObjects.visEditor.togglePieLegend(); + await PageObjects.visEditor.togglePieNestedLegend(); + await PageObjects.visEditor.clickDataTab(); + await PageObjects.visEditor.clickGo(); + } await PageObjects.visChart.filterLegend('CN'); await PageObjects.visChart.waitForVisualization(); await pieChart.expectPieChartLabels(expectedTableData); diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index b87184bab3c0d7..1e0e12a7d31bba 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -49,6 +49,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_point_series_options')); loadTestFile(require.resolve('./_vertical_bar_chart')); loadTestFile(require.resolve('./_vertical_bar_chart_nontimeindex')); + loadTestFile(require.resolve('./_pie_chart')); }); describe('visualize ciGroup9', function () { diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 7b69101b92475c..7ecf800b4be7c0 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -7,10 +7,12 @@ */ import { Position } from '@elastic/charts'; +import Color from 'color'; import { FtrProviderContext } from '../ftr_provider_context'; -const elasticChartSelector = 'visTypeXyChart'; +const xyChartSelector = 'visTypeXyChart'; +const pieChartSelector = 'visTypePieChart'; export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); @@ -25,8 +27,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr const { common } = getPageObjects(['common']); class VisualizeChart { - private async getDebugState() { - return await elasticChart.getChartDebugData(elasticChartSelector); + public async getEsChartDebugState(chartSelector: string) { + return await elasticChart.getChartDebugData(chartSelector); } /** @@ -45,32 +47,32 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr /** * Is new charts library enabled and an area, line or histogram chart exists */ - private async isVisTypeXYChart(): Promise { + public async isNewLibraryChart(chartSelector: string): Promise { const enabled = await this.isNewChartsLibraryEnabled(); if (!enabled) { - log.debug(`-- isVisTypeXYChart = false`); + log.debug(`-- isNewLibraryChart = false`); return false; } - // check if enabled but not a line, area or histogram chart + // check if enabled but not a line, area, histogram or pie chart if (await find.existsByCssSelector('.visLib__chart', 1)) { const chart = await find.byCssSelector('.visLib__chart'); const chartType = await chart.getAttribute('data-vislib-chart-type'); - if (!['line', 'area', 'histogram'].includes(chartType)) { - log.debug(`-- isVisTypeXYChart = false`); + if (!['line', 'area', 'histogram', 'pie'].includes(chartType)) { + log.debug(`-- isNewLibraryChart = false`); return false; } } - if (!(await elasticChart.hasChart(elasticChartSelector, 1))) { + if (!(await elasticChart.hasChart(chartSelector, 1))) { // not be a vislib chart type - log.debug(`-- isVisTypeXYChart = false`); + log.debug(`-- isNewLibraryChart = false`); return false; } - log.debug(`-- isVisTypeXYChart = true`); + log.debug(`-- isNewLibraryChart = true`); return true; } @@ -81,7 +83,7 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param elasticChartsValue value expected for `@elastic/charts` chart */ public async getExpectedValue(vislibValue: T, elasticChartsValue: T): Promise { - if (await this.isVisTypeXYChart()) { + if (await this.isNewLibraryChart(xyChartSelector)) { return elasticChartsValue; } @@ -89,8 +91,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getYAxisTitle() { - if (await this.isVisTypeXYChart()) { - const xAxis = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const xAxis = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return xAxis[0]?.title; } @@ -99,8 +101,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getXAxisLabels() { - if (await this.isVisTypeXYChart()) { - const [xAxis] = (await this.getDebugState())?.axes?.x ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const [xAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.x ?? []; return xAxis?.labels; } @@ -112,8 +114,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getYAxisLabels() { - if (await this.isVisTypeXYChart()) { - const [yAxis] = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return yAxis?.labels; } @@ -125,8 +127,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getYAxisLabelsAsNumbers() { - if (await this.isVisTypeXYChart()) { - const [yAxis] = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return yAxis?.values; } @@ -141,8 +143,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * Returns an array of height values */ public async getAreaChartData(dataLabel: string, axis = 'ValueAxis-1') { - if (await this.isVisTypeXYChart()) { - const areas = (await this.getDebugState())?.areas ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; const points = areas.find(({ name }) => name === dataLabel)?.lines.y1.points ?? []; return points.map(({ y }) => y); } @@ -183,8 +185,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param dataLabel data-label value */ public async getAreaChartPaths(dataLabel: string) { - if (await this.isVisTypeXYChart()) { - const areas = (await this.getDebugState())?.areas ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; const path = areas.find(({ name }) => name === dataLabel)?.path ?? ''; return path.split('L'); } @@ -208,9 +210,9 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param axis axis value, 'ValueAxis-1' by default */ public async getLineChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { - if (await this.isVisTypeXYChart()) { + if (await this.isNewLibraryChart(xyChartSelector)) { // For now lines are rendered as areas to enable stacking - const areas = (await this.getDebugState())?.areas ?? []; + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; const lines = areas.map(({ lines: { y1 }, name, color }) => ({ ...y1, name, color })); const points = lines.find(({ name }) => name === dataLabel)?.points ?? []; return points.map(({ y }) => y); @@ -248,8 +250,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr * @param axis axis value, 'ValueAxis-1' by default */ public async getBarChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { - if (await this.isVisTypeXYChart()) { - const bars = (await this.getDebugState())?.bars ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; const values = bars.find(({ name }) => name === dataLabel)?.bars ?? []; return values.map(({ y }) => y); } @@ -293,8 +295,9 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async toggleLegend(show = true) { - const isVisTypeXYChart = await this.isVisTypeXYChart(); - const legendSelector = isVisTypeXYChart ? '.echLegend' : '.visLegend'; + const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + const legendSelector = isVisTypeXYChart || isVisTypePieChart ? '.echLegend' : '.visLegend'; await retry.try(async () => { const isVisible = await find.existsByCssSelector(legendSelector); @@ -321,16 +324,25 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async doesSelectedLegendColorExist(color: string) { - if (await this.isVisTypeXYChart()) { - const items = (await this.getDebugState())?.legend?.items ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; return items.some(({ color: c }) => c === color); } + if (await this.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + return slices.some(({ color: c }) => { + const rgbColor = new Color(color).rgb().toString(); + return c === rgbColor; + }); + } + return await testSubjects.exists(`legendSelectedColor-${color}`); } public async expectError() { - if (!this.isVisTypeXYChart()) { + if (!this.isNewLibraryChart(xyChartSelector)) { await testSubjects.existOrFail('vislibVisualizeError'); } } @@ -371,17 +383,25 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr public async waitForVisualization() { await this.waitForVisualizationRenderingStabilized(); - if (!(await this.isVisTypeXYChart())) { + if (!(await this.isNewLibraryChart(xyChartSelector))) { await find.byCssSelector('.visualization'); } } public async getLegendEntries() { - if (await this.isVisTypeXYChart()) { - const items = (await this.getDebugState())?.legend?.items ?? []; + const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + if (isVisTypeXYChart) { + const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; return items.map(({ name }) => name); } + if (isVisTypePieChart) { + const slices = + (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + return slices.map(({ name }) => name); + } + const legendEntries = await find.allByCssSelector( '.visLegend__button', defaultFindTimeout * 2 @@ -391,10 +411,13 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr ); } - public async openLegendOptionColors(name: string, chartSelector = elasticChartSelector) { + public async openLegendOptionColors(name: string, chartSelector: string) { await this.waitForVisualizationRenderingStabilized(); await retry.try(async () => { - if (await this.isVisTypeXYChart()) { + if ( + (await this.isNewLibraryChart(xyChartSelector)) || + (await this.isNewLibraryChart(pieChartSelector)) + ) { const chart = await find.byCssSelector(chartSelector); const legendItemColor = await chart.findByCssSelector( `[data-ech-series-name="${name}"] .echLegendItem__color` @@ -408,7 +431,9 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr await this.waitForVisualizationRenderingStabilized(); // arbitrary color chosen, any available would do - const arbitraryColor = (await this.isVisTypeXYChart()) ? '#d36086' : '#EF843C'; + const arbitraryColor = (await this.isNewLibraryChart(xyChartSelector)) + ? '#d36086' + : '#EF843C'; const isOpen = await this.doesLegendColorChoiceExist(arbitraryColor); if (!isOpen) { throw new Error('legend color selector not open'); @@ -524,8 +549,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getRightValueAxesCount() { - if (await this.isVisTypeXYChart()) { - const yAxes = (await this.getDebugState())?.axes?.y ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const yAxes = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; return yAxes.filter(({ position }) => position === Position.Right).length; } const axes = await find.allByCssSelector('.visAxis__column--right g.axis'); @@ -544,8 +569,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getHistogramSeriesCount() { - if (await this.isVisTypeXYChart()) { - const bars = (await this.getDebugState())?.bars ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; return bars.filter(({ visible }) => visible).length; } @@ -554,8 +579,11 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getGridLines(): Promise> { - if (await this.isVisTypeXYChart()) { - const { x, y } = (await this.getDebugState())?.axes ?? { x: [], y: [] }; + if (await this.isNewLibraryChart(xyChartSelector)) { + const { x, y } = (await this.getEsChartDebugState(xyChartSelector))?.axes ?? { + x: [], + y: [], + }; return [...x, ...y].flatMap(({ gridlines }) => gridlines); } @@ -574,8 +602,8 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr } public async getChartValues() { - if (await this.isVisTypeXYChart()) { - const barSeries = (await this.getDebugState())?.bars ?? []; + if (await this.isNewLibraryChart(xyChartSelector)) { + const barSeries = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; return barSeries.filter(({ visible }) => visible).flatMap((bars) => bars.labels); } diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 59e93bd1f57007..47cbc8c5e3ea3f 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -327,6 +327,14 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP await testSubjects.click('visualizeEditorAutoButton'); } + public async togglePieLegend() { + await testSubjects.click('visTypePieAddLegendSwitch'); + } + + public async togglePieNestedLegend() { + await testSubjects.click('visTypePieNestedLegendSwitch'); + } + public async isApplyEnabled() { const applyButton = await testSubjects.find('visualizeEditorRenderButton'); return await applyButton.isEnabled(); diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts index cac4e8fe64c5e2..f51492d29b4506 100644 --- a/test/functional/services/visualizations/pie_chart.ts +++ b/test/functional/services/visualizations/pie_chart.ts @@ -9,6 +9,8 @@ import expect from '@kbn/expect'; import { FtrService } from '../../ftr_provider_context'; +const pieChartSelector = 'visTypePieChart'; + export class PieChartService extends FtrService { private readonly log = this.ctx.getService('log'); private readonly retry = this.ctx.getService('retry'); @@ -18,20 +20,42 @@ export class PieChartService extends FtrService { private readonly find = this.ctx.getService('find'); private readonly panelActions = this.ctx.getService('dashboardPanelActions'); private readonly defaultFindTimeout = this.config.get('timeouts.find'); + private readonly pageObjects = this.ctx.getPageObjects(['visChart']); private readonly filterActionText = 'Apply filter to current view'; async clickOnPieSlice(name?: string) { this.log.debug(`PieChart.clickOnPieSlice(${name})`); - if (name) { - await this.testSubjects.click(`pieSlice-${name.split(' ').join('-')}`); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + let sliceLabel = name || slices[0].name; + if (name === 'Other') { + sliceLabel = '__other__'; + } + const pieSlice = slices.find((slice) => slice.name === sliceLabel); + const pie = await this.testSubjects.find(pieChartSelector); + if (pieSlice) { + const pieSize = await pie.getSize(); + const pieHeight = pieSize.height; + const pieWidth = pieSize.width; + await pie.clickMouseButton({ + xOffset: pieSlice.coords[0] - Math.floor(pieWidth / 2), + yOffset: Math.floor(pieHeight / 2) - pieSlice.coords[1], + }); + } } else { - // If no pie slice has been provided, find the first one available. - await this.retry.try(async () => { - const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); - this.log.debug('Slices found:' + slices.length); - return slices[0].click(); - }); + if (name) { + await this.testSubjects.click(`pieSlice-${name.split(' ').join('-')}`); + } else { + // If no pie slice has been provided, find the first one available. + await this.retry.try(async () => { + const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); + this.log.debug('Slices found:' + slices.length); + return slices[0].click(); + }); + } } } @@ -63,12 +87,30 @@ export class PieChartService extends FtrService { async getPieSliceStyle(name: string) { this.log.debug(`VisualizePage.getPieSliceStyle(${name})`); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + const selectedSlice = slices.filter((slice) => { + return slice.name.toString() === name.replace(',', ''); + }); + return selectedSlice[0].color; + } const pieSlice = await this.getPieSlice(name); return await pieSlice.getAttribute('style'); } async getAllPieSliceStyles(name: string) { this.log.debug(`VisualizePage.getAllPieSliceStyles(${name})`); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + const selectedSlice = slices.filter((slice) => { + return slice.name.toString() === name.replace(',', ''); + }); + return selectedSlice.map((slice) => slice.color); + } const pieSlices = await this.getAllPieSlices(name); return await Promise.all( pieSlices.map(async (pieSlice) => await pieSlice.getAttribute('style')) @@ -87,6 +129,24 @@ export class PieChartService extends FtrService { } async getPieChartLabels() { + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + return slices.map((slice) => { + if (slice.name === '__missing__') { + return 'Missing'; + } else if (slice.name === '__other__') { + return 'Other'; + } else if (typeof slice.name === 'number') { + // debugState of escharts returns the numbers without comma + const val = slice.name as number; + return val.toString().replace(/\B(? await chart.getAttribute('data-label')) @@ -95,10 +155,23 @@ export class PieChartService extends FtrService { async getPieSliceCount() { this.log.debug('PieChart.getPieSliceCount'); + if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + return slices?.length; + } const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); return slices.length; } + async expectPieSliceCountEsCharts(expectedCount: number) { + const slices = + (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] + ?.partitions ?? []; + expect(slices.length).to.be(expectedCount); + } + async expectPieSliceCount(expectedCount: number) { this.log.debug(`PieChart.expectPieSliceCount(${expectedCount})`); await this.retry.try(async () => { @@ -111,7 +184,7 @@ export class PieChartService extends FtrService { this.log.debug(`PieChart.expectPieChartLabels(${expectedLabels.join(',')})`); await this.retry.try(async () => { const pieData = await this.getPieChartLabels(); - expect(pieData).to.eql(expectedLabels); + expect(pieData.sort()).to.eql(expectedLabels); }); } } diff --git a/tsconfig.json b/tsconfig.json index 37fc9ee05a29b0..c91f7b768a5c4e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -65,6 +65,7 @@ { "path": "./src/plugins/vis_type_vislib/tsconfig.json" }, { "path": "./src/plugins/vis_type_vega/tsconfig.json" }, { "path": "./src/plugins/vis_type_xy/tsconfig.json" }, + { "path": "./src/plugins/vis_type_pie/tsconfig.json" }, { "path": "./src/plugins/visualizations/tsconfig.json" }, { "path": "./src/plugins/visualize/tsconfig.json" }, { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 1b8a76d601e38c..9aa41cb9bc7559 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -52,6 +52,7 @@ { "path": "./src/plugins/vis_type_vislib/tsconfig.json" }, { "path": "./src/plugins/vis_type_vega/tsconfig.json" }, { "path": "./src/plugins/vis_type_xy/tsconfig.json" }, + { "path": "./src/plugins/vis_type_pie/tsconfig.json" }, { "path": "./src/plugins/visualizations/tsconfig.json" }, { "path": "./src/plugins/visualize/tsconfig.json" }, { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d1e6835b486b23..c2cad05ff9e304 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4902,12 +4902,6 @@ "visTypeVislib.editors.heatmap.heatmapSettingsTitle": "ヒートマップ設定", "visTypeVislib.editors.heatmap.highlightLabel": "ハイライト範囲", "visTypeVislib.editors.heatmap.highlightLabelTooltip": "チャートのカーソルを当てた部分と凡例の対応するラベルをハイライトします。", - "visTypeVislib.editors.pie.donutLabel": "ドーナッツ", - "visTypeVislib.editors.pie.labelsSettingsTitle": "ラベル設定", - "visTypeVislib.editors.pie.pieSettingsTitle": "パイ設定", - "visTypeVislib.editors.pie.showLabelsLabel": "ラベルを表示", - "visTypeVislib.editors.pie.showTopLevelOnlyLabel": "トップレベルのみ表示", - "visTypeVislib.editors.pie.showValuesLabel": "値を表示", "visTypeVislib.functions.pie.help": "パイビジュアライゼーション", "visTypeVislib.functions.vislib.help": "Vislib ビジュアライゼーション", "visTypeVislib.gauge.alignmentAutomaticTitle": "自動", @@ -4929,11 +4923,17 @@ "visTypeVislib.heatmap.metricTitle": "値", "visTypeVislib.heatmap.segmentTitle": "X 軸", "visTypeVislib.heatmap.splitTitle": "チャートを分割", - "visTypeVislib.pie.metricTitle": "スライスサイズ", - "visTypeVislib.pie.pieDescription": "全体に対する比率でデータを比較します。", - "visTypeVislib.pie.pieTitle": "円", - "visTypeVislib.pie.segmentTitle": "スライスの分割", - "visTypeVislib.pie.splitTitle": "チャートを分割", + "visTypePie.pie.metricTitle": "スライスサイズ", + "visTypePie.pie.pieDescription": "全体に対する比率でデータを比較します。", + "visTypePie.pie.pieTitle": "円", + "visTypePie.pie.segmentTitle": "スライスの分割", + "visTypePie.pie.splitTitle": "チャートを分割", + "visTypePie.editors.pie.donutLabel": "ドーナッツ", + "visTypePie.editors.pie.labelsSettingsTitle": "ラベル設定", + "visTypePie.editors.pie.pieSettingsTitle": "パイ設定", + "visTypePie.editors.pie.showLabelsLabel": "ラベルを表示", + "visTypePie.editors.pie.showTopLevelOnlyLabel": "トップレベルのみ表示", + "visTypePie.editors.pie.showValuesLabel": "値を表示", "visTypeVislib.vislib.errors.noResultsFoundTitle": "結果が見つかりませんでした", "visTypeVislib.vislib.heatmap.maxBucketsText": "定義された数列が多すぎます ({nr}) 。構成されている最大値は {max} です。", "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "値 {legendDataLabel} でフィルタリング", @@ -4945,8 +4945,8 @@ "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}、トグルオプション", "visTypeVislib.vislib.tooltip.fieldLabel": "フィールド", "visTypeVislib.vislib.tooltip.valueLabel": "値", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description": "Visualizeでエリア、折れ線、棒グラフのレガシーグラフライブラリを有効にします。", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name": "レガシーグラフライブラリ", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.description": "Visualizeでエリア、折れ線、棒グラフのレガシーグラフライブラリを有効にします。", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.name": "レガシーグラフライブラリ", "visTypeXy.aggResponse.allDocsTitle": "すべてのドキュメント", "visTypeXy.area.areaDescription": "軸と線の間のデータを強調します。", "visTypeXy.area.areaTitle": "エリア", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 97f3ebdb733968..d3a3d9ae30c378 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4929,12 +4929,6 @@ "visTypeVislib.editors.heatmap.heatmapSettingsTitle": "热图设置", "visTypeVislib.editors.heatmap.highlightLabel": "高亮范围", "visTypeVislib.editors.heatmap.highlightLabelTooltip": "高亮显示图表中鼠标悬停的范围以及图例中对应的标签。", - "visTypeVislib.editors.pie.donutLabel": "圆环图", - "visTypeVislib.editors.pie.labelsSettingsTitle": "标签设置", - "visTypeVislib.editors.pie.pieSettingsTitle": "饼图设置", - "visTypeVislib.editors.pie.showLabelsLabel": "显示标签", - "visTypeVislib.editors.pie.showTopLevelOnlyLabel": "仅显示顶级", - "visTypeVislib.editors.pie.showValuesLabel": "显示值", "visTypeVislib.functions.pie.help": "饼图可视化", "visTypeVislib.functions.vislib.help": "Vislib 可视化", "visTypeVislib.gauge.alignmentAutomaticTitle": "自动", @@ -4956,11 +4950,17 @@ "visTypeVislib.heatmap.metricTitle": "值", "visTypeVislib.heatmap.segmentTitle": "X 轴", "visTypeVislib.heatmap.splitTitle": "拆分图表", - "visTypeVislib.pie.metricTitle": "切片大小", - "visTypeVislib.pie.pieDescription": "以整体的比例比较数据。", - "visTypeVislib.pie.pieTitle": "饼图", - "visTypeVislib.pie.segmentTitle": "拆分切片", - "visTypeVislib.pie.splitTitle": "拆分图表", + "visTypePie.pie.metricTitle": "切片大小", + "visTypePie.pie.pieDescription": "以整体的比例比较数据。", + "visTypePie.pie.pieTitle": "饼图", + "visTypePie.pie.segmentTitle": "拆分切片", + "visTypePie.pie.splitTitle": "拆分图表", + "visTypePie.editors.pie.donutLabel": "圆环图", + "visTypePie.editors.pie.labelsSettingsTitle": "标签设置", + "visTypePie.editors.pie.pieSettingsTitle": "饼图设置", + "visTypePie.editors.pie.showLabelsLabel": "显示标签", + "visTypePie.editors.pie.showTopLevelOnlyLabel": "仅显示顶级", + "visTypePie.editors.pie.showValuesLabel": "显示值", "visTypeVislib.vislib.errors.noResultsFoundTitle": "找不到结果", "visTypeVislib.vislib.heatmap.maxBucketsText": "定义了过多的序列 ({nr})。配置的最大值为 {max}。", "visTypeVislib.vislib.legend.filterForValueButtonAriaLabel": "筛留值 {legendDataLabel}", @@ -4972,8 +4972,8 @@ "visTypeVislib.vislib.legend.toggleOptionsButtonAriaLabel": "{legendDataLabel}, 切换选项", "visTypeVislib.vislib.tooltip.fieldLabel": "字段", "visTypeVislib.vislib.tooltip.valueLabel": "值", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description": "在 Visualize 中启用面积图、折线图和条形图的旧版图表库。", - "visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name": "旧版图表库", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.description": "在 Visualize 中启用面积图、折线图和条形图的旧版图表库。", + "visualizations.advancedSettings.visualization.legacyChartsLibrary.name": "旧版图表库", "visTypeXy.aggResponse.allDocsTitle": "所有文档", "visTypeXy.area.areaDescription": "突出轴与线之间的数据。", "visTypeXy.area.areaTitle": "面积图", diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts index b891d3cce3ba09..1660bbff10d37e 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts @@ -24,6 +24,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'settings', 'copySavedObjectsToSpace', ]); + const queryBar = getService('queryBar'); const pieChart = getService('pieChart'); const log = getService('log'); const browser = getService('browser'); @@ -31,6 +32,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const filterBar = getService('filterBar'); const security = getService('security'); const spaces = getService('spaces'); + const elasticChart = getService('elasticChart'); describe('Dashboard to dashboard drilldown', function () { describe('Create & use drilldowns', () => { @@ -211,7 +213,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await navigateWithinDashboard(async () => { await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME); }); - await pieChart.expectPieSliceCount(10); + await elasticChart.setNewChartUiDebugFlag(); + await queryBar.submitQuery(); + await pieChart.expectPieSliceCountEsCharts(10); }); }); }); From a0c20ac7aaf5b5667b4bb78d270825e039995431 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 3 Jun 2021 11:58:25 -0400 Subject: [PATCH 44/77] [Dashboard] Fix Copy To Permission & Unskip RBAC tests (#100616) * slightly better typing for dashboard permissions. Fixed typo, unskipped functional tests --- src/plugins/dashboard/common/types.ts | 8 ++++++ .../application/dashboard_app_functions.ts | 4 +-- .../embeddable/dashboard_container.tsx | 6 ++--- .../dashboard_empty_screen.test.tsx.snap | 4 +++ .../empty_screen/dashboard_empty_screen.tsx | 6 ++++- .../hooks/use_dashboard_container.test.tsx | 4 +-- .../listing/dashboard_listing.test.tsx | 4 +-- .../application/top_nav/show_share_modal.tsx | 6 ++--- .../dashboard/public/application/types.ts | 4 +-- src/plugins/dashboard/public/plugin.tsx | 8 ++++-- .../dashboard/server/capabilities_provider.ts | 6 ++++- .../feature_controls/dashboard_security.ts | 26 +++++++++++-------- .../time_to_visualize_security.ts | 3 +-- 13 files changed, 58 insertions(+), 31 deletions(-) diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index 9a6d185ef2ac10..5851ffa045bc7f 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -32,6 +32,14 @@ export interface DashboardPanelState< panelRefName?: string; } +export interface DashboardCapabilities { + showWriteControls: boolean; + saveQuery: boolean; + createNew: boolean; + show: boolean; + [key: string]: boolean; +} + /** * This should always represent the latest dashboard panel shape, after all possible migrations. */ diff --git a/src/plugins/dashboard/public/application/dashboard_app_functions.ts b/src/plugins/dashboard/public/application/dashboard_app_functions.ts index 6d51422d4bd23e..895a56242bf96e 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_functions.ts +++ b/src/plugins/dashboard/public/application/dashboard_app_functions.ts @@ -21,7 +21,7 @@ import { switchMap, } from 'rxjs/operators'; -import { DashboardCapabilities } from './types'; +import { DashboardAppCapabilities } from './types'; import { DashboardConstants } from '../dashboard_constants'; import { DashboardStateManager } from './dashboard_state_manager'; import { convertSavedDashboardPanelToPanelState } from '../../common/embeddable/embeddable_saved_object_converters'; @@ -103,7 +103,7 @@ export const getDashboardContainerInput = ({ dashboardStateManager, dashboardCapabilities, }: { - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; dashboardStateManager: DashboardStateManager; incomingEmbeddable?: EmbeddablePackageState; lastReloadRequestTime?: number; diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 92b0727d2458cf..847a190a6e083a 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -37,11 +37,11 @@ import { } from '../../services/kibana_react'; import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; -import { DashboardCapabilities } from '../types'; +import { DashboardAppCapabilities } from '../types'; import { PresentationUtilPluginStart } from '../../services/presentation_util'; export interface DashboardContainerInput extends ContainerInput { - dashboardCapabilities?: DashboardCapabilities; + dashboardCapabilities?: DashboardAppCapabilities; refreshConfig?: RefreshInterval; isEmbeddedExternally?: boolean; isFullScreenMode: boolean; @@ -91,7 +91,7 @@ export interface InheritedChildInput extends IndexSignature { export type DashboardReactContextValue = KibanaReactContextValue; export type DashboardReactContext = KibanaReactContext; -const defaultCapabilities: DashboardCapabilities = { +const defaultCapabilities: DashboardAppCapabilities = { show: false, createNew: false, saveQuery: false, diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 44beed5e4a89be..ae8943e9f6b3e3 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -590,10 +590,12 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = `
{ return ( - + toasts: coreMock.createStart().notifications.toasts, }); -const defaultCapabilities: DashboardCapabilities = { +const defaultCapabilities: DashboardAppCapabilities = { show: false, createNew: false, saveQuery: false, diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx index 022c830b180b67..febb03d58d9341 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx @@ -24,7 +24,7 @@ import { savedObjectsPluginMock } from '../../../../saved_objects/public/mocks'; import { DashboardListing, DashboardListingProps } from './dashboard_listing'; import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; import { visualizationsPluginMock } from '../../../../visualizations/public/mocks'; -import { DashboardAppServices, DashboardCapabilities } from '../types'; +import { DashboardAppServices, DashboardAppCapabilities } from '../types'; import { dataPluginMock } from '../../../../data/public/mocks'; import { chromeServiceMock, coreMock } from '../../../../../core/public/mocks'; import { I18nProvider } from '@kbn/i18n/react'; @@ -59,7 +59,7 @@ function makeDefaultServices(): DashboardAppServices { return { savedObjects: savedObjectsPluginMock.createStartContract(), embeddable: embeddablePluginMock.createInstance().doStart(), - dashboardCapabilities: {} as DashboardCapabilities, + dashboardCapabilities: {} as DashboardAppCapabilities, initializerContext: {} as PluginInitializerContext, chrome: chromeServiceMock.createStartContract(), navigation: {} as NavigationPublicPluginStart, diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx index 56823adf6bc142..a96b1ebd4f1ff0 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx @@ -16,7 +16,7 @@ import { SharePluginStart } from '../../services/share'; import { dashboardUrlParams } from '../dashboard_router'; import { DashboardStateManager } from '../dashboard_state_manager'; import { shareModalStrings } from '../../dashboard_strings'; -import { DashboardCapabilities } from '../types'; +import { DashboardAppCapabilities } from '../types'; const showFilterBarId = 'showFilterBar'; @@ -24,14 +24,14 @@ interface ShowShareModalProps { share: SharePluginStart; anchorElement: HTMLElement; savedDashboard: DashboardSavedObject; - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; dashboardStateManager: DashboardStateManager; } export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { if (!anonymousUserCapabilities.dashboard) return false; - const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardCapabilities; + const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardAppCapabilities; return !!dashboard.show; }; diff --git a/src/plugins/dashboard/public/application/types.ts b/src/plugins/dashboard/public/application/types.ts index dd291291ce9d61..aae8a1f6eca540 100644 --- a/src/plugins/dashboard/public/application/types.ts +++ b/src/plugins/dashboard/public/application/types.ts @@ -49,7 +49,7 @@ export interface DashboardSaveOptions { isTitleDuplicateConfirmed: boolean; } -export interface DashboardCapabilities { +export interface DashboardAppCapabilities { visualizeCapabilities: { save: boolean }; mapsCapabilities: { save: boolean }; hideWriteControls: boolean; @@ -77,7 +77,7 @@ export interface DashboardAppServices { usageCollection?: UsageCollectionSetup; navigation: NavigationPublicPluginStart; dashboardPanelStorage: DashboardPanelStorage; - dashboardCapabilities: DashboardCapabilities; + dashboardCapabilities: DashboardAppCapabilities; initializerContext: PluginInitializerContext; onAppLeave: AppMountParameters['onAppLeave']; savedObjectsTagging?: SavedObjectsTaggingApi; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 230918399d88f8..b73fe5f2ba410d 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -65,6 +65,7 @@ import { AddToLibraryAction, LibraryNotificationAction, CopyToDashboardAction, + DashboardCapabilities, } from './application'; import { createDashboardUrlGenerator, @@ -351,6 +352,9 @@ export class DashboardPlugin const { notifications, overlays, application } = core; const { uiActions, data, share, presentationUtil, embeddable } = plugins; + const dashboardCapabilities: Readonly = application.capabilities + .dashboard as DashboardCapabilities; + const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); const expandPanelAction = new ExpandPanelAction(); @@ -395,8 +399,8 @@ export class DashboardPlugin overlays, embeddable.getStateTransfer(), { - canCreateNew: Boolean(application.capabilities.dashboard.createNew), - canEditExisting: !Boolean(application.capabilities.dashboard.hideWriteControls), + canCreateNew: Boolean(dashboardCapabilities.createNew), + canEditExisting: Boolean(dashboardCapabilities.showWriteControls), }, presentationUtil.ContextProvider ); diff --git a/src/plugins/dashboard/server/capabilities_provider.ts b/src/plugins/dashboard/server/capabilities_provider.ts index 25457c1a487d90..c5b740c5812941 100644 --- a/src/plugins/dashboard/server/capabilities_provider.ts +++ b/src/plugins/dashboard/server/capabilities_provider.ts @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -export const capabilitiesProvider = () => ({ +import { DashboardCapabilities } from '../common/types'; + +export const capabilitiesProvider = (): { + dashboard: DashboardCapabilities; +} => ({ dashboard: { createNew: true, show: true, diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index bdbfb5050a32f9..94a0eedd07c54f 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -31,8 +31,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const queryBar = getService('queryBar'); const savedQueryManagementComponent = getService('savedQueryManagementComponent'); - // FLAKY: https://github.com/elastic/kibana/issues/86950 - describe.skip('dashboard feature controls security', () => { + describe('dashboard feature controls security', () => { before(async () => { await esArchiver.load('dashboard/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); @@ -86,7 +85,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('only shows the dashboard navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link) => link.text)).to.eql(['Overview', 'Dashboard']); + expect(navLinks.map((link) => link.text)).to.eql([ + 'Overview', + 'Dashboard', + 'Stack Management', + ]); }); it(`landing page shows "Create new Dashboard" button`, async () => { @@ -108,8 +111,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeMissingOrFail(); }); - // Can't figure out how to get this test to pass - it.skip(`create new dashboard shows addNew button`, async () => { + it(`create new dashboard shows addNew button`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -320,8 +322,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeExistsOrFail('Read only'); }); - // Has this behavior changed? - it.skip(`create new dashboard redirects to the home page`, async () => { + it(`create new dashboard shows the read only warning`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -330,7 +331,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, } ); - await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + await testSubjects.existOrFail('dashboardEmptyReadOnly', { timeout: 20000 }); }); it(`can view existing Dashboard`, async () => { @@ -347,6 +348,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); + it('does not allow copy to dashboard behaviour', async () => { + await panelActions.expectMissingPanelAction('embeddablePanelAction-copyToDashboard'); + }); + it(`Permalinks doesn't show create short-url button`, async () => { await PageObjects.share.openShareMenuItem('Permalinks'); await PageObjects.share.createShortUrlMissingOrFail(); @@ -438,8 +443,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await globalNav.badgeExistsOrFail('Read only'); }); - // Has this behavior changed? - it.skip(`create new dashboard redirects to the home page`, async () => { + it(`create new dashboard shows the read only warning`, async () => { await PageObjects.common.navigateToActualUrl( 'dashboard', DashboardConstants.CREATE_NEW_DASHBOARD_URL, @@ -448,7 +452,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldLoginIfPrompted: false, } ); - await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + await testSubjects.existOrFail('dashboardEmptyReadOnly', { timeout: 20000 }); }); it(`can view existing Dashboard`, async () => { diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts index 2c151235518e08..730c00a8d5e4f1 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts @@ -29,8 +29,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const security = getService('security'); const find = getService('find'); - // flaky https://github.com/elastic/kibana/issues/98249 - describe.skip('dashboard time to visualize security', () => { + describe('dashboard time to visualize security', () => { before(async () => { await esArchiver.load('dashboard/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); From 0312839e34ba3e8519abd1fab7d7f94cfef98ba1 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Thu, 3 Jun 2021 12:27:06 -0400 Subject: [PATCH 45/77] [Security Solution][Endpoint][Host Isolation] Unisolate host minor refactors (#100889) --- .../common/endpoint/constants.ts | 25 ++++++----- .../host_isolation/isolate_success.tsx | 35 +++++++-------- .../utils/resolve_path_variables.test.ts | 45 +++++++++++++++++++ .../common/utils/resolve_path_variables.ts | 11 +++++ .../components/host_isolation/index.tsx | 8 ---- .../components/host_isolation/isolate.tsx | 13 +----- .../components/host_isolation/translations.ts | 3 +- .../components/host_isolation/unisolate.tsx | 13 +----- .../containers/detection_engine/alerts/api.ts | 6 +-- .../public/management/common/utils.test.ts | 37 +-------------- .../public/management/common/utils.ts | 5 --- .../pages/endpoint_hosts/store/middleware.ts | 14 +++--- .../pages/trusted_apps/service/index.ts | 2 +- .../view/trusted_apps_page.test.tsx | 2 +- .../server/endpoint/routes/metadata/index.ts | 12 ++--- .../endpoint/routes/metadata/metadata.test.ts | 27 ++++++----- .../apis/metadata.ts | 30 ++++++------- 17 files changed, 141 insertions(+), 147 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index c85778f2f38fad..cdfc34c2e9cda2 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -15,23 +15,24 @@ export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; -export const HOST_METADATA_LIST_API = '/api/endpoint/metadata'; -export const HOST_METADATA_GET_API = '/api/endpoint/metadata/{id}'; +export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; +export const HOST_METADATA_LIST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; +export const HOST_METADATA_GET_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata/{id}`; -export const TRUSTED_APPS_GET_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; -export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; -export const TRUSTED_APPS_UPDATE_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_DELETE_API = '/api/endpoint/trusted_apps/{id}'; -export const TRUSTED_APPS_SUMMARY_API = '/api/endpoint/trusted_apps/summary'; +export const TRUSTED_APPS_GET_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_LIST_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps`; +export const TRUSTED_APPS_CREATE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps`; +export const TRUSTED_APPS_UPDATE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_DELETE_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/{id}`; +export const TRUSTED_APPS_SUMMARY_API = `${BASE_ENDPOINT_ROUTE}/trusted_apps/summary`; -export const BASE_POLICY_RESPONSE_ROUTE = `/api/endpoint/policy_response`; -export const BASE_POLICY_ROUTE = `/api/endpoint/policy`; +export const BASE_POLICY_RESPONSE_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy_response`; +export const BASE_POLICY_ROUTE = `${BASE_ENDPOINT_ROUTE}/policy`; export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; /** Host Isolation Routes */ -export const ISOLATE_HOST_ROUTE = `/api/endpoint/isolate`; -export const UNISOLATE_HOST_ROUTE = `/api/endpoint/unisolate`; +export const ISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/isolate`; +export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; /** Endpoint Actions Log Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx index f822b3c287a02a..3459da068b2824 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_success.tsx @@ -27,25 +27,22 @@ export const EndpointIsolateSuccess = memo( }) => { return ( <> - {isolateAction === 'isolateHost' ? ( - - {additionalInfo} - - ) : ( - - {additionalInfo} - - )} - + + {additionalInfo} + { + describe('resolvePathVariables', () => { + it('should resolve defined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( + '/segment1/value1/segment2' + ); + }); + + it('should not resolve undefined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should ignore unused variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should replace multiple variable occurences', () => { + expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( + '/value1/segment1/value1' + ); + }); + + it('should replace multiple variables', () => { + const path = resolvePathVariables('/{var1}/segment1/{var2}', { + var1: 'value1', + var2: 'value2', + }); + + expect(path).toBe('/value1/segment1/value2'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts b/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts new file mode 100644 index 00000000000000..89067e575665dd --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/resolve_path_variables.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => + Object.keys(variables).reduce((acc, paramName) => { + return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); + }, path); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx index bb1585b5392bd5..2ca84168414979 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/index.tsx @@ -36,12 +36,6 @@ export const HostIsolationPanel = React.memo( return findHostName ? findHostName[0] : ''; }, [details]); - const alertRule = useMemo(() => { - const findAlertRule = find({ category: 'signal', field: 'signal.rule.name' }, details) - ?.values; - return findAlertRule ? findAlertRule[0] : ''; - }, [details]); - const alertId = useMemo(() => { const findAlertId = find({ category: '_id', field: '_id' }, details)?.values; return findAlertId ? findAlertId[0] : ''; @@ -95,7 +89,6 @@ export const HostIsolationPanel = React.memo( void; @@ -80,15 +78,9 @@ export const IsolateHost = React.memo( messageAppend={ - {caseCount} - {CASES_ASSOCIATED_WITH_ALERT(caseCount)} - {alertRule} - - ), + cases: {CASES_ASSOCIATED_WITH_ALERT(caseCount)}, }} /> } @@ -103,7 +95,6 @@ export const IsolateHost = React.memo( comment, loading, caseCount, - alertRule, ]); return isIsolated ? hostIsolatedSuccess : hostNotIsolated; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts index 449a09b932cd31..98b74817cabb67 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/translations.ts @@ -25,7 +25,8 @@ export const CASES_ASSOCIATED_WITH_ALERT = (caseCount: number): string => i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.isolateHost.casesAssociatedWithAlert', { - defaultMessage: ' {caseCount, plural, one {case} other {cases}} associated with the rule ', + defaultMessage: + '{caseCount} {caseCount, plural, one {case} other {cases}} associated with this host', values: { caseCount }, } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx index 74149f2a692d3c..e72a0d2de61bc7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/unisolate.tsx @@ -20,14 +20,12 @@ export const UnisolateHost = React.memo( ({ agentId, hostName, - alertRule, cases, caseIds, cancelCallback, }: { agentId: string; hostName: string; - alertRule: string; cases: ReactNode; caseIds: string[]; cancelCallback: () => void; @@ -80,15 +78,9 @@ export const UnisolateHost = React.memo( messageAppend={ - {caseCount} - {CASES_ASSOCIATED_WITH_ALERT(caseCount)} - {alertRule} - - ), + cases: {CASES_ASSOCIATED_WITH_ALERT(caseCount)}, }} /> } @@ -103,7 +95,6 @@ export const UnisolateHost = React.memo( comment, loading, caseCount, - alertRule, ]); return isUnIsolated ? hostUnisolatedSuccess : hostNotUnisolated; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index a7bd42c6af5ee5..cd596ef76ce0ac 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -14,7 +14,7 @@ import { DETECTION_ENGINE_INDEX_URL, DETECTION_ENGINE_PRIVILEGES_URL, } from '../../../../../common/constants'; -import { HOST_METADATA_GET_API } from '../../../../../common/endpoint/constants'; +import { HOST_METADATA_GET_ROUTE } from '../../../../../common/endpoint/constants'; import { KibanaServices } from '../../../../common/lib/kibana'; import { BasicSignals, @@ -25,8 +25,8 @@ import { UpdateAlertStatusProps, CasesFromAlertsResponse, } from './types'; -import { resolvePathVariables } from '../../../../management/common/utils'; import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; /** * Fetch Alerts by providing a query @@ -181,6 +181,6 @@ export const getHostMetadata = async ({ agentId: string; }): Promise => KibanaServices.get().http.fetch( - resolvePathVariables(HOST_METADATA_GET_API, { id: agentId }), + resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: agentId }), { method: 'get' } ); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.test.ts b/x-pack/plugins/security_solution/public/management/common/utils.test.ts index 8918261b6a4365..59455ccd6bb042 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { parseQueryFilterToKQL, resolvePathVariables } from './utils'; +import { parseQueryFilterToKQL } from './utils'; describe('utils', () => { const searchableFields = [`name`, `description`, `entries.value`, `entries.entries.value`]; @@ -39,39 +39,4 @@ describe('utils', () => { ); }); }); - - describe('resolvePathVariables', () => { - it('should resolve defined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( - '/segment1/value1/segment2' - ); - }); - - it('should not resolve undefined variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should ignore unused variables', () => { - expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( - '/segment1/{var1}/segment2' - ); - }); - - it('should replace multiple variable occurences', () => { - expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( - '/value1/segment1/value1' - ); - }); - - it('should replace multiple variables', () => { - const path = resolvePathVariables('/{var1}/segment1/{var2}', { - var1: 'value1', - var2: 'value2', - }); - - expect(path).toBe('/value1/segment1/value2'); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/utils.ts b/x-pack/plugins/security_solution/public/management/common/utils.ts index 78a95eb4d6f81e..c8cf761ccaf864 100644 --- a/x-pack/plugins/security_solution/public/management/common/utils.ts +++ b/x-pack/plugins/security_solution/public/management/common/utils.ts @@ -19,8 +19,3 @@ export const parseQueryFilterToKQL = (filter: string, fields: Readonly return kuery; }; - -export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => - Object.keys(variables).reduce((acc, paramName) => { - return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); - }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 90427d5003384f..911a902bd2029e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -41,12 +41,11 @@ import { import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; import { ENDPOINT_ACTION_LOG_ROUTE, - HOST_METADATA_GET_API, - HOST_METADATA_LIST_API, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, metadataCurrentIndexPattern, } from '../../../../../common/endpoint/constants'; import { IIndexPattern, Query } from '../../../../../../../../src/plugins/data/public'; -import { resolvePathVariables } from '../../../common/utils'; import { createFailedResourceState, createLoadedResourceState, @@ -54,6 +53,7 @@ import { } from '../../../state'; import { isolateHost } from '../../../../common/lib/host_isolation'; import { AppAction } from '../../../../common/store/actions'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; type EndpointPageStore = ImmutableMiddlewareAPI; @@ -104,7 +104,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory(HOST_METADATA_LIST_API, { + endpointResponse = await coreStart.http.post(HOST_METADATA_LIST_ROUTE, { body: JSON.stringify({ paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }], filters: { kql: decodedQuery.query }, @@ -253,7 +253,7 @@ export const endpointMiddlewareFactory: ImmutableMiddlewareFactory( - resolvePathVariables(HOST_METADATA_GET_API, { id: selectedEndpoint as string }) + resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: selectedEndpoint as string }) ); dispatch({ type: 'serverReturnedEndpointDetails', @@ -458,7 +458,7 @@ const getAgentAndPoliciesForEndpointsList = async ( const endpointsTotal = async (http: HttpStart): Promise => { try { return ( - await http.post(HOST_METADATA_LIST_API, { + await http.post(HOST_METADATA_LIST_ROUTE, { body: JSON.stringify({ paging_properties: [{ page_index: 0 }, { page_size: 1 }], }), diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 01bccc81b5063b..9d39ecd05ad8a6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -29,8 +29,8 @@ import { GetOneTrustedAppRequestParams, GetOneTrustedAppResponse, } from '../../../../../common/endpoint/types/trusted_apps'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; -import { resolvePathVariables } from '../../../common/utils'; import { sendGetEndpointSpecificPackagePolicies } from '../../policy/store/services/ingest'; export interface TrustedAppsService { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index dc0032243312f0..fac9fb1e5bf6e5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -31,9 +31,9 @@ import { import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from './components/effected_policy_select/test_utils'; -import { resolvePathVariables } from '../../../common/utils'; import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index 44db86f85cf5f2..b4784c1ff5ed40 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -11,12 +11,14 @@ import { HostStatus, MetadataQueryStrategyVersions } from '../../../../common/en import { EndpointAppContext } from '../../types'; import { getLogger, getMetadataListRequestHandler, getMetadataRequestHandler } from './handlers'; import type { SecuritySolutionPluginRouter } from '../../../types'; +import { + BASE_ENDPOINT_ROUTE, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, +} from '../../../../common/endpoint/constants'; -export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; export const METADATA_REQUEST_V1_ROUTE = `${BASE_ENDPOINT_ROUTE}/v1/metadata`; export const GET_METADATA_REQUEST_V1_ROUTE = `${METADATA_REQUEST_V1_ROUTE}/{id}`; -export const METADATA_REQUEST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; -export const GET_METADATA_REQUEST_ROUTE = `${METADATA_REQUEST_ROUTE}/{id}`; /* Filters that can be applied to the endpoint fetch route */ export const endpointFilters = schema.object({ @@ -82,7 +84,7 @@ export function registerEndpointRoutes( router.post( { - path: `${METADATA_REQUEST_ROUTE}`, + path: `${HOST_METADATA_LIST_ROUTE}`, validate: GetMetadataListRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, @@ -100,7 +102,7 @@ export function registerEndpointRoutes( router.get( { - path: `${GET_METADATA_REQUEST_ROUTE}`, + path: `${HOST_METADATA_GET_ROUTE}`, validate: GetMetadataRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index b916ec19da17ff..e6d6879ba18450 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -26,7 +26,7 @@ import { MetadataQueryStrategyVersions, } from '../../../../common/endpoint/types'; import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; -import { registerEndpointRoutes, METADATA_REQUEST_ROUTE } from './index'; +import { registerEndpointRoutes } from './index'; import { createMockEndpointAppContextServiceStartContract, createMockPackageService, @@ -45,7 +45,10 @@ import { } from '../../../../../fleet/common/types/models'; import { createV1SearchResponse, createV2SearchResponse } from './support/test_support'; import { PackageService } from '../../../../../fleet/server/services'; -import { metadataTransformPrefix } from '../../../../common/endpoint/constants'; +import { + HOST_METADATA_LIST_ROUTE, + metadataTransformPrefix, +} from '../../../../common/endpoint/constants'; import type { SecuritySolutionPluginRouter } from '../../../types'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; import { @@ -126,7 +129,7 @@ describe('test endpoint route', () => { Promise.resolve({ body: response }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); @@ -167,7 +170,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -225,7 +228,7 @@ describe('test endpoint route', () => { Promise.resolve({ body: response }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); @@ -273,7 +276,7 @@ describe('test endpoint route', () => { }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -332,7 +335,7 @@ describe('test endpoint route', () => { }) ); [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -416,7 +419,7 @@ describe('test endpoint route', () => { } as unknown) as Agent); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), @@ -449,7 +452,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -490,7 +493,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -525,7 +528,7 @@ describe('test endpoint route', () => { ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( @@ -558,7 +561,7 @@ describe('test endpoint route', () => { } as unknown) as Agent); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${METADATA_REQUEST_ROUTE}`) + path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) )!; await routeHandler( diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 8dd5adba43edbd..13a19e55ab5887 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -12,8 +12,8 @@ import { deleteAllDocsFromMetadataIndex, deleteMetadataStream, } from './data_stream_helper'; -import { METADATA_REQUEST_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; import { MetadataQueryStrategyVersions } from '../../../plugins/security_solution/common/endpoint/types'; +import { HOST_METADATA_LIST_ROUTE } from '../../../plugins/security_solution/common/endpoint/constants'; /** * The number of host documents in the es archive. @@ -25,13 +25,13 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('test metadata api', () => { - describe(`POST ${METADATA_REQUEST_ROUTE} when index is empty`, () => { + describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is empty`, () => { it('metadata api should return empty result when index is empty', async () => { await deleteMetadataStream(getService); await deleteAllDocsFromMetadataIndex(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send() .expect(200); @@ -42,7 +42,7 @@ export default function ({ getService }: FtrProviderContext) { }); }); - describe(`POST ${METADATA_REQUEST_ROUTE} when index is not empty`, () => { + describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is not empty`, () => { before(async () => { await esArchiver.load('endpoint/metadata/api_feature', { useCreate: true }); // wait for transform @@ -57,7 +57,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('metadata api should return one entry for each host with default paging', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send() .expect(200); @@ -69,7 +69,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on paging properties passed.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -94,7 +94,7 @@ export default function ({ getService }: FtrProviderContext) { */ it('metadata api should return accurate total metadata if page index produces no result', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -116,7 +116,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return 400 when pagingProperties is below boundaries.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -134,7 +134,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on filters passed.', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -152,7 +152,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on filters and paging passed.', async () => { const notIncludedIp = '10.46.229.234'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -190,7 +190,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on host.os.Ext.variant filter.', async () => { const variantValue = 'Windows Pro'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -212,7 +212,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return the latest event for all the events for an endpoint', async () => { const targetEndpointIp = '10.46.229.234'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -234,7 +234,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return the latest event for all the events where policy status is not success', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -255,7 +255,7 @@ export default function ({ getService }: FtrProviderContext) { const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -278,7 +278,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return all hosts when filter is empty string', async () => { const { body } = await supertest - .post(`${METADATA_REQUEST_ROUTE}`) + .post(`${HOST_METADATA_LIST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { From 3b1e8b03f162244c88452e5da4df00eb73741f4a Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 3 Jun 2021 12:27:29 -0400 Subject: [PATCH 46/77] [Fleet] Install final pipeline (#100973) --- .../ingest_pipeline/final_pipeline.ts | 107 ++++++++++ .../elasticsearch/ingest_pipeline/install.ts | 23 +++ .../__snapshots__/template.test.ts.snap | 9 +- .../epm/elasticsearch/template/template.ts | 6 + x-pack/plugins/fleet/server/services/setup.ts | 3 + .../apis/epm/final_pipeline.ts | 187 ++++++++++++++++++ .../fleet_api_integration/apis/epm/index.js | 1 + 7 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts create mode 100644 x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts new file mode 100644 index 00000000000000..4c0484c058abf0 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/final_pipeline.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const FINAL_PIPELINE_ID = '.fleet_final_pipeline'; + +export const FINAL_PIPELINE = `--- +description: > + Final pipeline for processing all incoming Fleet Agent documents. +processors: + - set: + description: Add time when event was ingested. + field: event.ingested + value: '{{{_ingest.timestamp}}}' + - remove: + description: Remove any pre-existing untrusted values. + field: + - event.agent_id_status + - _security + ignore_missing: true + - set_security_user: + field: _security + properties: + - authentication_type + - username + - realm + - api_key + - script: + description: > + Add event.agent_id_status based on the API key metadata and the + agent.id contained in the event. + tag: agent-id-status + source: |- + boolean is_user_trusted(def ctx, def users) { + if (ctx?._security?.username == null) { + return false; + } + + def user = null; + for (def item : users) { + if (item?.username == ctx._security.username) { + user = item; + break; + } + } + + if (user == null || user?.realm == null || ctx?._security?.realm?.name == null) { + return false; + } + + if (ctx._security.realm.name != user.realm) { + return false; + } + + return true; + } + + String verified(def ctx, def params) { + // Agents only use API keys. + if (ctx?._security?.authentication_type == null || ctx._security.authentication_type != 'API_KEY') { + return "no_api_key"; + } + + // Verify the API key owner before trusting any metadata it contains. + if (!is_user_trusted(ctx, params.trusted_users)) { + return "untrusted_user"; + } + + // API keys created by Fleet include metadata about the agent they were issued to. + if (ctx?._security?.api_key?.metadata?.agent_id == null || ctx?.agent?.id == null) { + return "missing_metadata"; + } + + // The API key can only be used represent the agent.id it was issued to. + if (ctx._security.api_key.metadata.agent_id != ctx.agent.id) { + // Potential masquerade attempt. + return "agent_id_mismatch"; + } + + return "verified"; + } + + if (ctx?.event == null) { + ctx.event = [:]; + } + + ctx.event.agent_id_status = verified(ctx, params); + params: + # List of users responsible for creating Fleet output API keys. + trusted_users: + - username: elastic + realm: reserved + - remove: + field: _security + ignore_missing: true +on_failure: + - remove: + field: _security + ignore_missing: true + ignore_failure: true + - append: + field: error.message + value: + - 'failed in Fleet agent final_pipeline: {{ _ingest.on_failure_message }}'`; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts index ac5aca7ab1c143..1d212f188120f4 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -16,6 +16,7 @@ import { saveInstalledEsRefs } from '../../packages/install'; import { getInstallationObject } from '../../packages'; import { deletePipelineRefs } from './remove'; +import { FINAL_PIPELINE, FINAL_PIPELINE_ID } from './final_pipeline'; interface RewriteSubstitution { source: string; @@ -185,6 +186,28 @@ async function installPipeline({ return { id: pipeline.nameForInstallation, type: ElasticsearchAssetType.ingestPipeline }; } +export async function ensureFleetFinalPipelineIsInstalled(esClient: ElasticsearchClient) { + const esClientRequestOptions: TransportRequestOptions = { + ignore: [404], + }; + const res = await esClient.ingest.getPipeline({ id: FINAL_PIPELINE_ID }, esClientRequestOptions); + + if (res.statusCode === 404) { + await esClient.ingest.putPipeline( + // @ts-ignore pipeline is define in yaml + { id: FINAL_PIPELINE_ID, body: FINAL_PIPELINE }, + { + headers: { + // pipeline is YAML + 'Content-Type': 'application/yaml', + // but we want JSON responses (to extract error messages, status code, or other metadata) + Accept: 'application/json', + }, + } + ); + } +} + const isDirectory = ({ path }: ArchiveEntry) => path.endsWith('/'); const isDataStreamPipeline = (path: string, dataStreamDataset: string) => { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index 65eec939d58502..acf8ae742bf8f1 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -25,7 +25,8 @@ exports[`EPM template tests loading base.yml: base.yml 1`] = ` "default_field": [ "long.nested.foo" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { @@ -139,7 +140,8 @@ exports[`EPM template tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` "coredns.response.code", "coredns.response.flags" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { @@ -281,7 +283,8 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "system.users.scope", "system.users.remote_host" ] - } + }, + "final_pipeline": ".fleet_final_pipeline" } }, "mappings": { 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 64261226a79441..5dd2755390ecb1 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 @@ -16,6 +16,7 @@ import type { } from '../../../../types'; import { appContextService } from '../../../'; import { getRegistryDataStreamAssetBaseName } from '../index'; +import { FINAL_PIPELINE_ID } from '../ingest_pipeline/final_pipeline'; interface Properties { [key: string]: any; @@ -86,6 +87,11 @@ export function getTemplate({ if (pipelineName) { template.template.settings.index.default_pipeline = pipelineName; } + if (template.template.settings.index.final_pipeline) { + throw new Error(`Error template for ${templateIndexPattern} contains a final_pipeline`); + } + template.template.settings.index.final_pipeline = FINAL_PIPELINE_ID; + return template; } diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 28deec8a890283..7f4219799e511e 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -20,6 +20,7 @@ import { settingsService } from '.'; import { awaitIfPending } from './setup_utils'; import { ensureAgentActionPolicyChangeExists } from './agents'; import { awaitIfFleetServerSetupPending } from './fleet_server'; +import { ensureFleetFinalPipelineIsInstalled } from './epm/elasticsearch/ingest_pipeline/install'; export interface SetupStatus { isInitialized: boolean; @@ -42,6 +43,8 @@ async function createSetupSideEffects( settingsService.settingsSetup(soClient), ]); + await ensureFleetFinalPipelineIsInstalled(esClient); + await awaitIfFleetServerSetupPending(); const { agentPolicies: policiesOrUndefined, packages: packagesOrUndefined } = diff --git a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts new file mode 100644 index 00000000000000..1ab7b00da5d763 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts @@ -0,0 +1,187 @@ +/* + * 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 '../../../api_integration/ftr_provider_context'; +import { setupFleetAndAgents } from '../agents/services'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +const TEST_INDEX = 'logs-log.log-test'; + +const FINAL_PIPELINE_ID = '.fleet_final_pipeline'; + +let pkgKey: string; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + function indexUsingApiKey(body: any, apiKey: string): Promise<{ body: Record }> { + const supertestWithoutAuth = getService('esSupertestWithoutAuth'); + return supertestWithoutAuth + .post(`/${TEST_INDEX}/_doc`) + .set('Authorization', `ApiKey ${apiKey}`) + .send(body) + .expect(201); + } + + describe('fleet_final_pipeline', () => { + skipIfNoDockerRegistry(providerContext); + before(async () => { + await esArchiver.load('fleet/empty_fleet_server'); + }); + setupFleetAndAgents(providerContext); + + // Use the custom log package to test the fleet final pipeline + before(async () => { + const { body: getPackagesRes } = await supertest.get( + `/api/fleet/epm/packages?experimental=true` + ); + + const logPackage = getPackagesRes.response.find((p: any) => p.name === 'log'); + if (!logPackage) { + throw new Error('No log package'); + } + + pkgKey = `log-${logPackage.version}`; + + await supertest + .post(`/api/fleet/epm/packages/${pkgKey}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }) + .expect(200); + }); + after(async () => { + await supertest + .delete(`/api/fleet/epm/packages/${pkgKey}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }) + .expect(200); + }); + + after(async () => { + await esArchiver.unload('fleet/empty_fleet_server'); + }); + + after(async () => { + const res = await es.search({ + index: TEST_INDEX, + }); + + for (const hit of res.body.hits.hits) { + await es.delete({ + id: hit._id, + index: hit._index, + }); + } + }); + + it('should correctly setup the final pipeline and apply to fleet managed index template', async () => { + const pipelineRes = await es.ingest.getPipeline({ id: FINAL_PIPELINE_ID }); + expect(pipelineRes.body).to.have.property(FINAL_PIPELINE_ID); + + const res = await es.indices.getIndexTemplate({ name: 'logs-log.log' }); + expect(res.body.index_templates.length).to.be(1); + expect( + res.body.index_templates[0]?.index_template?.template?.settings?.index?.final_pipeline + ).to.be(FINAL_PIPELINE_ID); + }); + + it('For a doc written without api key should write the correct api key status', async () => { + const res = await es.index({ + index: 'logs-log.log-test', + body: { + message: 'message-test-1', + '@timestamp': '2020-01-01T09:09:00', + agent: { + id: 'agent1', + }, + }, + }); + + const { body: doc } = await es.get({ + id: res.body._id, + index: res.body._index, + }); + // @ts-expect-error + const event = doc._source.event; + + expect(event.agent_id_status).to.be('no_api_key'); + expect(event).to.have.property('ingested'); + }); + + const scenarios = [ + { + name: 'API key without metadata', + expectedStatus: 'missing_metadata', + event: { agent: { id: 'agent1' } }, + }, + { + name: 'API key with agent id metadata', + expectedStatus: 'verified', + apiKey: { + metadata: { + agent_id: 'agent1', + }, + }, + event: { agent: { id: 'agent1' } }, + }, + { + name: 'API key with agent id metadata and no agent id in event', + expectedStatus: 'missing_metadata', + apiKey: { + metadata: { + agent_id: 'agent1', + }, + }, + }, + { + name: 'API key with agent id metadata and tampered agent id in event', + expectedStatus: 'agent_id_mismatch', + apiKey: { + metadata: { + agent_id: 'agent2', + }, + }, + event: { agent: { id: 'agent1' } }, + }, + ]; + + for (const scenario of scenarios) { + it(`Should write the correct event.agent_id_status for ${scenario.name}`, async () => { + // Create an API key + const { body: apiKeyRes } = await es.security.createApiKey({ + body: { + name: `test api key`, + ...(scenario.apiKey || {}), + }, + }); + + const res = await indexUsingApiKey( + { + message: 'message-test-1', + '@timestamp': '2020-01-01T09:09:00', + ...(scenario.event || {}), + }, + Buffer.from(`${apiKeyRes.id}:${apiKeyRes.api_key}`).toString('base64') + ); + + const { body: doc } = await es.get({ + id: res.body._id as string, + index: res.body._index as string, + }); + // @ts-expect-error + const event = doc._source.event; + + expect(event.agent_id_status).to.be(scenario.expectedStatus); + expect(event).to.have.property('ingested'); + }); + } + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/epm/index.js b/x-pack/test/fleet_api_integration/apis/epm/index.js index 445d9706bb9a93..b6a1fd5d7346d9 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/index.js +++ b/x-pack/test/fleet_api_integration/apis/epm/index.js @@ -25,5 +25,6 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./data_stream')); loadTestFile(require.resolve('./package_install_complete')); loadTestFile(require.resolve('./install_error_rollback')); + loadTestFile(require.resolve('./final_pipeline')); }); } From 07ce6374ef4996452eabad56ffa1022c8fcc7471 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Thu, 3 Jun 2021 19:29:57 +0300 Subject: [PATCH 47/77] Bump packages (#101167) * Bump mini-css-extract-plugin * Removed unused dependency * Remove unecessary types * Don't match _meta field --- package.json | 4 +- .../index_lifecycle_management/policies.js | 3 + yarn.lock | 95 ++----------------- 3 files changed, 11 insertions(+), 91 deletions(-) diff --git a/package.json b/package.json index e0bebcaacd7eaa..fd81b86c7da6ed 100644 --- a/package.json +++ b/package.json @@ -241,7 +241,6 @@ "get-port": "^5.0.0", "getopts": "^2.2.5", "getos": "^3.1.0", - "git-url-parse": "11.1.2", "github-markdown-css": "^2.10.0", "glob": "^7.1.2", "glob-all": "^3.2.1", @@ -294,7 +293,7 @@ "memoize-one": "^5.0.0", "mime": "^2.4.4", "mime-types": "^2.1.27", - "mini-css-extract-plugin": "0.8.0", + "mini-css-extract-plugin": "1.1.0", "minimatch": "^3.0.4", "moment": "^2.24.0", "moment-duration-format": "^2.3.2", @@ -533,7 +532,6 @@ "@types/geojson": "7946.0.7", "@types/getopts": "^2.0.1", "@types/getos": "^3.0.0", - "@types/git-url-parse": "^9.0.0", "@types/glob": "^7.1.2", "@types/gulp": "^4.0.6", "@types/gulp-zip": "^4.0.1", 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 8f40f5826c537c..a07b9666685452 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 @@ -45,6 +45,9 @@ export default function ({ getService }) { const modifiedDate = '2019-04-30T14:30:00.000Z'; policy.modified_date = modifiedDate; + // We don't want to match `_meta` field since it can change between Elasticsearch versions + delete policy.policy._meta; + expect(policy).to.eql({ version: 1, modified_date: modifiedDate, diff --git a/yarn.lock b/yarn.lock index 032c5255130d97..8b472845169785 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4953,11 +4953,6 @@ resolved "https://registry.yarnpkg.com/@types/getos/-/getos-3.0.0.tgz#582c758e99e9d634f31f471faf7ce59cf1c39a71" integrity sha512-g5O9kykBPMaK5USwU+zM5AyXaztqbvHjSQ7HaBjqgO3f5lKGChkRhLP58Z/Nrr4RBGNNPrBcJkWZwnmbmi9YjQ== -"@types/git-url-parse@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" - integrity sha512-kA2RxBT/r/ZuDDKwMl+vFWn1Z0lfm1/Ik6Qb91wnSzyzCDa/fkM8gIOq6ruB7xfr37n6Mj5dyivileUVKsidlg== - "@types/glob-base@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@types/glob-base/-/glob-base-0.3.0.tgz#a581d688347e10e50dd7c17d6f2880a10354319d" @@ -14372,21 +14367,6 @@ gifwrap@^0.9.2: image-q "^1.1.1" omggif "^1.0.10" -git-up@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/git-up/-/git-up-4.0.1.tgz#cb2ef086653640e721d2042fe3104857d89007c0" - integrity sha512-LFTZZrBlrCrGCG07/dm1aCjjpL1z9L3+5aEeI9SBhAqSc+kiA9Or1bgZhQFNppJX6h/f5McrvJt1mQXTFm6Qrw== - dependencies: - is-ssh "^1.3.0" - parse-url "^5.0.0" - -git-url-parse@11.1.2: - version "11.1.2" - resolved "https://registry.yarnpkg.com/git-url-parse/-/git-url-parse-11.1.2.tgz#aff1a897c36cc93699270587bea3dbcbbb95de67" - integrity sha512-gZeLVGY8QVKMIkckncX+iCq2/L8PlwncvDFKiWkBn9EtCfYDbliRTTp6qzyQ1VMdITUfq7293zDzfpjdiGASSQ== - dependencies: - git-up "^4.0.0" - github-markdown-css@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/github-markdown-css/-/github-markdown-css-2.10.0.tgz#0612fed22816b33b282f37ef8def7a4ecabfe993" @@ -16546,13 +16526,6 @@ is-set@^2.0.1: resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43" integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA== -is-ssh@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/is-ssh/-/is-ssh-1.3.1.tgz#f349a8cadd24e65298037a522cf7520f2e81a0f3" - integrity sha512-0eRIASHZt1E68/ixClI8bp2YK2wmBPVWEismTs6M+M099jKgrzl/3E976zIbImSIob48N2/XGe9y7ZiYdImSlg== - dependencies: - protocols "^1.1.0" - is-stream@^1.0.0, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -19306,14 +19279,13 @@ mini-create-react-context@^0.4.0: "@babel/runtime" "^7.5.5" tiny-warning "^1.0.3" -mini-css-extract-plugin@0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1" - integrity sha512-MNpRGbNA52q6U92i0qbVpQNsgk7LExy41MdAlG84FeytfDOtRIf/mCHdEgG8rpTKOaNKiqUnZdlptF469hxqOw== +mini-css-extract-plugin@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.1.0.tgz#dcc2f0bfbec660c0bd1200ff7c8f82deec2cc8a6" + integrity sha512-0bTS+Fg2tGe3dFAgfiN7+YRO37oyQM7/vjFvZF1nXSCJ/sy0tGpeme8MbT4BCpUuUphKwTh9LH/uuTcWRr9DPA== dependencies: - loader-utils "^1.1.0" - normalize-url "1.9.1" - schema-utils "^1.0.0" + loader-utils "^2.0.0" + schema-utils "^3.0.0" webpack-sources "^1.1.0" minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: @@ -20260,17 +20232,7 @@ normalize-selector@^0.2.0: resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03" integrity sha1-0LFF62kRicY6eNIB3E/bEpPvDAM= -normalize-url@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" - integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= - dependencies: - object-assign "^4.0.1" - prepend-http "^1.0.0" - query-string "^4.1.0" - sort-keys "^1.0.0" - -normalize-url@^3.0.0, normalize-url@^3.3.0: +normalize-url@^3.0.0: version "3.3.0" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== @@ -21117,24 +21079,6 @@ parse-passwd@^1.0.0: resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= -parse-path@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/parse-path/-/parse-path-4.0.1.tgz#0ec769704949778cb3b8eda5e994c32073a1adff" - integrity sha512-d7yhga0Oc+PwNXDvQ0Jv1BuWkLVPXcAoQ/WREgd6vNNoKYaW52KI+RdOFjI63wjkmps9yUE8VS4veP+AgpQ/hA== - dependencies: - is-ssh "^1.3.0" - protocols "^1.4.0" - -parse-url@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/parse-url/-/parse-url-5.0.1.tgz#99c4084fc11be14141efa41b3d117a96fcb9527f" - integrity sha512-flNUPP27r3vJpROi0/R3/2efgKkyXqnXwyP1KQ2U0SfFRgdizOdWfvrrvJg1LuOoxs7GQhmxJlq23IpQ/BkByg== - dependencies: - is-ssh "^1.3.0" - normalize-url "^3.3.0" - parse-path "^4.0.0" - protocols "^1.4.0" - parse5@5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" @@ -22277,11 +22221,6 @@ protocol-buffers-schema@^3.3.1: resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.3.2.tgz#00434f608b4e8df54c59e070efeefc37fb4bb859" integrity sha512-Xdayp8sB/mU+sUV4G7ws8xtYMGdQnxbeIfLjyO9TZZRJdztBGhlmbI5x1qcY4TG5hBkIKGnc28i7nXxaugu88w== -protocols@^1.1.0, protocols@^1.4.0: - version "1.4.7" - resolved "https://registry.yarnpkg.com/protocols/-/protocols-1.4.7.tgz#95f788a4f0e979b291ffefcf5636ad113d037d32" - integrity sha512-Fx65lf9/YDn3hUX08XUc0J8rSux36rEsyiv21ZGUC1mOyeM3lTRpZLcrm8aAolzS4itwVfm7TAPyxC2E5zd6xg== - proxy-addr@~2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34" @@ -22455,14 +22394,6 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== -query-string@^4.1.0: - version "4.3.4" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" - integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s= - dependencies: - object-assign "^4.1.0" - strict-uri-encode "^1.0.0" - query-string@^6.13.2: version "6.13.2" resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.13.2.tgz#3585aa9412c957cbd358fd5eaca7466f05586dda" @@ -25242,13 +25173,6 @@ sonic-boom@^1.0.2: atomic-sleep "^1.0.0" flatstr "^1.0.12" -sort-keys@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" - integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0= - dependencies: - is-plain-obj "^1.0.0" - sort-keys@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128" @@ -25793,11 +25717,6 @@ stream-to-async-iterator@^0.2.0: resolved "https://registry.yarnpkg.com/stream-to-async-iterator/-/stream-to-async-iterator-0.2.0.tgz#bef5c885e9524f98b2fa5effecc357bd58483780" integrity sha1-vvXIhelST5iy+l7/7MNXvVhIN4A= -strict-uri-encode@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" - integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= - strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" From f69d63e8be8052d8400021fa91c440bf79c05925 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 3 Jun 2021 17:53:39 +0100 Subject: [PATCH 48/77] fix(NA): windows ts_project outside sandbox compilation (#100947) * fix(NA): windows ts_project outside sandbox compilation adding tsconfig paths for packages * chore(NA): missing @kbn paths for node_modules so types can work * chore(NA): missing @kbn paths for node_modules so types can work * chore(NA): organizing deps on non ts_project packages * chore(NA): change order to find @kbn packages on node_modules first * chore(NA): add @kbn/expect typings setting on package.json * chore(NA): fix typechecking * chore(NA): add missing change on tsconfig file * chore(NA): unblock windows build by not depending on the pkg_npm rule symlink in the package.json * chore(NA): add missing depedencies on BUILD.bazel file for io-ts-list-types * chore(NA): remove rootDirs configs * chore(NA): change kbn/monaco targets order * chore(NA): update kbn-monaco build Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 82 +++++++++---------- packages/kbn-babel-code-parser/BUILD.bazel | 4 +- packages/kbn-es/BUILD.bazel | 4 +- packages/kbn-expect/BUILD.bazel | 2 +- .../{expect.js.d.ts => expect.d.ts} | 0 packages/kbn-expect/package.json | 1 + packages/kbn-expect/tsconfig.json | 2 +- packages/kbn-monaco/BUILD.bazel | 4 +- packages/kbn-monaco/webpack.config.js | 1 - packages/kbn-pm/dist/index.js | 4 +- packages/kbn-pm/src/utils/package_json.ts | 2 +- packages/kbn-pm/src/utils/project.ts | 2 +- .../BUILD.bazel | 3 +- test/functional/page_objects/error_page.ts | 2 +- .../page_objects/visualize_editor_page.ts | 2 +- tsconfig.base.json | 7 ++ .../spaces_only/tests/alerting/update.ts | 2 +- .../apis/lists/create_exception_list_item.ts | 2 +- .../api_integration/apis/security/api_keys.ts | 2 +- .../apis/security/builtin_es_privileges.ts | 2 +- .../apis/security/index_fields.ts | 2 +- .../apis/security/license_downgrade.ts | 2 +- .../apis/security/privileges.ts | 2 +- .../apis/spaces/saved_objects.ts | 2 +- .../apis/agents/upgrade.ts | 2 +- .../page_objects/infra_home_page.ts | 2 +- .../functional/services/uptime/monitor.ts | 2 +- .../event_log/public_api_integration.ts | 2 +- .../event_log/service_api_integration.ts | 2 +- .../common/suites/delete.ts | 2 +- .../tests/session_idle/extension.ts | 2 +- .../apis/metadata.ts | 2 +- .../apis/metadata_v1.ts | 2 +- .../apis/policy.ts | 2 +- yarn.lock | 82 +++++++++---------- 35 files changed, 124 insertions(+), 116 deletions(-) rename packages/kbn-expect/{expect.js.d.ts => expect.d.ts} (100%) diff --git a/package.json b/package.json index fd81b86c7da6ed..f0803b3b440569 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", "@elastic/charts": "29.2.0", - "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath/npm_module", + "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.13.0", "@elastic/eui": "33.0.0", @@ -111,7 +111,7 @@ "@elastic/numeral": "^2.5.1", "@elastic/react-search-ui": "^1.5.1", "@elastic/request-crypto": "1.1.4", - "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set/npm_module", + "@elastic/safer-lodash-set": "link:bazel-bin/packages/elastic-safer-lodash-set", "@elastic/search-ui-app-search-connector": "^1.5.0", "@elastic/ui-ace": "0.2.3", "@hapi/accept": "^5.0.2", @@ -124,39 +124,39 @@ "@hapi/inert": "^6.0.3", "@hapi/podium": "^4.1.1", "@hapi/wreck": "^17.1.0", - "@kbn/ace": "link:bazel-bin/packages/kbn-ace/npm_module", - "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics/npm_module", - "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader/npm_module", - "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils/npm_module", - "@kbn/config": "link:bazel-bin/packages/kbn-config/npm_module", - "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module", - "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto/npm_module", - "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n/npm_module", + "@kbn/ace": "link:bazel-bin/packages/kbn-ace", + "@kbn/analytics": "link:bazel-bin/packages/kbn-analytics", + "@kbn/apm-config-loader": "link:bazel-bin/packages/kbn-apm-config-loader", + "@kbn/apm-utils": "link:bazel-bin/packages/kbn-apm-utils", + "@kbn/config": "link:bazel-bin/packages/kbn-config", + "@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema", + "@kbn/crypto": "link:bazel-bin/packages/kbn-crypto", + "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", + "@kbn/i18n": "link:bazel-bin/packages/kbn-i18n", "@kbn/interpreter": "link:packages/kbn-interpreter", - "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils/npm_module", - "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging/npm_module", - "@kbn/logging": "link:bazel-bin/packages/kbn-logging/npm_module", - "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl/npm_module", - "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco/npm_module", + "@kbn/io-ts-utils": "link:bazel-bin/packages/kbn-io-ts-utils", + "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", + "@kbn/logging": "link:bazel-bin/packages/kbn-logging", + "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco", "@kbn/rule-data-utils": "link:packages/kbn-rule-data-utils", - "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils/npm_module", - "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types/npm_module", - "@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types/npm_module", - "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module", - "@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils/npm_module", - "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api/npm_module", - "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module", - "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks/npm_module", - "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils/npm_module", - "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils/npm_module", - "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools/npm_module", + "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants", + "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils", + "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types", + "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types", + "@kbn/securitysolution-io-ts-list-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types", + "@kbn/securitysolution-io-ts-utils": "link:bazel-bin/packages/kbn-securitysolution-io-ts-utils", + "@kbn/securitysolution-list-api": "link:bazel-bin/packages/kbn-securitysolution-list-api", + "@kbn/securitysolution-list-hooks": "link:bazel-bin/packages/kbn-securitysolution-list-hooks", + "@kbn/securitysolution-list-utils": "link:bazel-bin/packages/kbn-securitysolution-list-utils", + "@kbn/securitysolution-utils": "link:bazel-bin/packages/kbn-securitysolution-utils", + "@kbn/server-http-tools": "link:bazel-bin/packages/kbn-server-http-tools", "@kbn/server-route-repository": "link:packages/kbn-server-route-repository", - "@kbn/std": "link:bazel-bin/packages/kbn-std/npm_module", - "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath/npm_module", + "@kbn/std": "link:bazel-bin/packages/kbn-std", + "@kbn/tinymath": "link:bazel-bin/packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", - "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types/npm_module", - "@kbn/utils": "link:bazel-bin/packages/kbn-utils/npm_module", + "@kbn/utility-types": "link:bazel-bin/packages/kbn-utility-types", + "@kbn/utils": "link:bazel-bin/packages/kbn-utils", "@loaders.gl/core": "^2.3.1", "@loaders.gl/json": "^2.3.1", "@mapbox/geojson-rewind": "^0.5.0", @@ -446,28 +446,28 @@ "@cypress/webpack-preprocessor": "^5.6.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana/npm_module", + "@elastic/eslint-config-kibana": "link:bazel-bin/packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", "@elastic/makelogs": "^6.0.0", "@istanbuljs/schema": "^0.1.2", "@jest/reporters": "^26.6.2", - "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser/npm_module", - "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset/npm_module", + "@kbn/babel-code-parser": "link:bazel-bin/packages/kbn-babel-code-parser", + "@kbn/babel-preset": "link:bazel-bin/packages/kbn-babel-preset", "@kbn/cli-dev-mode": "link:packages/kbn-cli-dev-mode", - "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils/npm_module", - "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils/npm_module", - "@kbn/es": "link:bazel-bin/packages/kbn-es/npm_module", + "@kbn/dev-utils": "link:bazel-bin/packages/kbn-dev-utils", + "@kbn/docs-utils": "link:bazel-bin/packages/kbn-docs-utils", + "@kbn/es": "link:bazel-bin/packages/kbn-es", "@kbn/es-archiver": "link:packages/kbn-es-archiver", - "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana/npm_module", - "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint/npm_module", - "@kbn/expect": "link:bazel-bin/packages/kbn-expect/npm_module", + "@kbn/eslint-import-resolver-kibana": "link:bazel-bin/packages/kbn-eslint-import-resolver-kibana", + "@kbn/eslint-plugin-eslint": "link:bazel-bin/packages/kbn-eslint-plugin-eslint", + "@kbn/expect": "link:bazel-bin/packages/kbn-expect", "@kbn/optimizer": "link:packages/kbn-optimizer", - "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator/npm_module", + "@kbn/plugin-generator": "link:bazel-bin/packages/kbn-plugin-generator", "@kbn/plugin-helpers": "link:packages/kbn-plugin-helpers", "@kbn/pm": "link:packages/kbn-pm", "@kbn/storybook": "link:packages/kbn-storybook", - "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools/npm_module", + "@kbn/telemetry-tools": "link:bazel-bin/packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", "@kbn/test-subj-selector": "link:packages/kbn-test-subj-selector", "@loaders.gl/polyfills": "^2.3.5", diff --git a/packages/kbn-babel-code-parser/BUILD.bazel b/packages/kbn-babel-code-parser/BUILD.bazel index c1576ce59aa5c0..dcdc042d7b8029 100644 --- a/packages/kbn-babel-code-parser/BUILD.bazel +++ b/packages/kbn-babel-code-parser/BUILD.bazel @@ -34,10 +34,10 @@ DEPS = [ babel( name = "target", - data = [ + data = DEPS + [ ":srcs", ".babelrc", - ] + DEPS, + ], output_dir = True, # the following arg paths includes $(execpath) as babel runs on the sandbox root args = [ diff --git a/packages/kbn-es/BUILD.bazel b/packages/kbn-es/BUILD.bazel index 6c845996ce5e5a..48f0fb58e983fc 100644 --- a/packages/kbn-es/BUILD.bazel +++ b/packages/kbn-es/BUILD.bazel @@ -47,10 +47,10 @@ DEPS = [ babel( name = "target", - data = [ + data = DEPS + [ ":srcs", ".babelrc", - ] + DEPS, + ], output_dir = True, # the following arg paths includes $(execpath) as babel runs on the sandbox root args = [ diff --git a/packages/kbn-expect/BUILD.bazel b/packages/kbn-expect/BUILD.bazel index 82e6200e9688a8..b7eb91a451b9a5 100644 --- a/packages/kbn-expect/BUILD.bazel +++ b/packages/kbn-expect/BUILD.bazel @@ -5,7 +5,7 @@ PKG_REQUIRE_NAME = "@kbn/expect" SOURCE_FILES = glob([ "expect.js", - "expect.js.d.ts", + "expect.d.ts", ]) SRCS = SOURCE_FILES diff --git a/packages/kbn-expect/expect.js.d.ts b/packages/kbn-expect/expect.d.ts similarity index 100% rename from packages/kbn-expect/expect.js.d.ts rename to packages/kbn-expect/expect.d.ts diff --git a/packages/kbn-expect/package.json b/packages/kbn-expect/package.json index 8ca37c7c88673f..2040683c539e2d 100644 --- a/packages/kbn-expect/package.json +++ b/packages/kbn-expect/package.json @@ -1,6 +1,7 @@ { "name": "@kbn/expect", "main": "./expect.js", + "typings": "./expect.d.ts", "version": "1.0.0", "license": "MIT", "private": true, diff --git a/packages/kbn-expect/tsconfig.json b/packages/kbn-expect/tsconfig.json index 7baae093bc3a98..8c0d9f1e34bd0c 100644 --- a/packages/kbn-expect/tsconfig.json +++ b/packages/kbn-expect/tsconfig.json @@ -4,6 +4,6 @@ "incremental": false, }, "include": [ - "expect.js.d.ts" + "expect.d.ts" ] } diff --git a/packages/kbn-monaco/BUILD.bazel b/packages/kbn-monaco/BUILD.bazel index 3a25568dfd811d..325187cdebc3ab 100644 --- a/packages/kbn-monaco/BUILD.bazel +++ b/packages/kbn-monaco/BUILD.bazel @@ -48,7 +48,7 @@ webpack( name = "target_web", data = DEPS + [ ":src", - ":webpack.config.js", + "webpack.config.js", ], output_dir = True, args = [ @@ -87,7 +87,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = DEPS + [":target_web", ":tsc"], + deps = DEPS + [":tsc", ":target_web"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) diff --git a/packages/kbn-monaco/webpack.config.js b/packages/kbn-monaco/webpack.config.js index d035134565463e..ef482cd55159bf 100644 --- a/packages/kbn-monaco/webpack.config.js +++ b/packages/kbn-monaco/webpack.config.js @@ -22,7 +22,6 @@ const createLangWorkerConfig = (lang) => { filename: `${lang}.editor.worker.js`, }, resolve: { - modules: ['node_modules'], extensions: ['.js', '.ts', '.tsx'], }, stats: 'errors-only', diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 29c0457c316f06..4c4c0259f066b8 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -23045,7 +23045,7 @@ class Project { ensureValidProjectDependency(project) { const relativePathToProject = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, project.path)); - const relativePathToProjectIfBazelPkg = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, `${__dirname}/../../../bazel-bin/packages/${path__WEBPACK_IMPORTED_MODULE_1___default.a.basename(project.path)}/npm_module`)); + const relativePathToProjectIfBazelPkg = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, `${__dirname}/../../../bazel-bin/packages/${path__WEBPACK_IMPORTED_MODULE_1___default.a.basename(project.path)}`)); const versionInPackageJson = this.allDependencies[project.name]; const expectedVersionInPackageJson = `link:${relativePathToProject}`; const expectedVersionInPackageJsonIfBazelPkg = `link:${relativePathToProjectIfBazelPkg}`; // TODO: after introduce bazel to build all the packages and completely remove the support for kbn packages @@ -23234,7 +23234,7 @@ function transformDependencies(dependencies = {}) { } if (isBazelPackageDependency(depVersion)) { - newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:').replace('/npm_module', ''); + newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:'); continue; } diff --git a/packages/kbn-pm/src/utils/package_json.ts b/packages/kbn-pm/src/utils/package_json.ts index e635c2566e65ac..a50d8994b5720a 100644 --- a/packages/kbn-pm/src/utils/package_json.ts +++ b/packages/kbn-pm/src/utils/package_json.ts @@ -61,7 +61,7 @@ export function transformDependencies(dependencies: IPackageDependencies = {}) { } if (isBazelPackageDependency(depVersion)) { - newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:').replace('/npm_module', ''); + newDeps[name] = depVersion.replace('link:bazel-bin/', 'file:'); continue; } diff --git a/packages/kbn-pm/src/utils/project.ts b/packages/kbn-pm/src/utils/project.ts index 5d2a0547b25772..8e86b111c6a182 100644 --- a/packages/kbn-pm/src/utils/project.ts +++ b/packages/kbn-pm/src/utils/project.ts @@ -94,7 +94,7 @@ export class Project { const relativePathToProjectIfBazelPkg = normalizePath( Path.relative( this.path, - `${__dirname}/../../../bazel-bin/packages/${Path.basename(project.path)}/npm_module` + `${__dirname}/../../../bazel-bin/packages/${Path.basename(project.path)}` ) ); diff --git a/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel b/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel index 91e4667c16b4e9..99df07c3d8ea8a 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel +++ b/packages/kbn-securitysolution-io-ts-list-types/BUILD.bazel @@ -27,9 +27,10 @@ NPM_MODULE_EXTRA_FILES = [ ] SRC_DEPS = [ + "//packages/elastic-datemath", "//packages/kbn-securitysolution-io-ts-types", "//packages/kbn-securitysolution-io-ts-utils", - "//packages/elastic-datemath", + "//packages/kbn-securitysolution-list-constants", "@npm//fp-ts", "@npm//io-ts", "@npm//lodash", diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index e3d5a7fdf57c24..98096f3179d020 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 47cbc8c5e3ea3f..9ba1ab6f85081f 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/tsconfig.base.json b/tsconfig.base.json index eca78e492ff5e3..cc8b66848a394c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,6 +2,13 @@ "compilerOptions": { "baseUrl": ".", "paths": { + // Setup @kbn paths for Bazel compilations + "@kbn/*": [ + "node_modules/@kbn/*", + "bazel-out/darwin-fastbuild/bin/packages/kbn-*", + "bazel-out/k8-fastbuild/bin/packages/kbn-*", + "bazel-out/x64_windows-fastbuild/bin/packages/kbn-*", + ], // Allows for importing from `kibana` package for the exported types. "kibana": ["./kibana"], "kibana/public": ["src/core/public"], diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index 318da3e1140979..3d98e428fd9ee4 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { Spaces } from '../../scenarios'; import { checkAAD, getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts index db3cdd17a89dc1..fb80d81dd242a4 100644 --- a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts +++ b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/security/api_keys.ts b/x-pack/test/api_integration/apis/security/api_keys.ts index c79c4f3eaa88ec..d2614abc9e5f7b 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts index 9e4f5c8af8b05f..c927d095b88897 100644 --- a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts +++ b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/index_fields.ts b/x-pack/test/api_integration/apis/security/index_fields.ts index 442740c7666df9..3f036bcd7f7ea7 100644 --- a/x-pack/test/api_integration/apis/security/index_fields.ts +++ b/x-pack/test/api_integration/apis/security/index_fields.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/license_downgrade.ts b/x-pack/test/api_integration/apis/security/license_downgrade.ts index 583df6ea5ed07b..7a5ad1ce64a62a 100644 --- a/x-pack/test/api_integration/apis/security/license_downgrade.ts +++ b/x-pack/test/api_integration/apis/security/license_downgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index f08712e0156566..d6ad5f6cd387be 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -7,7 +7,7 @@ import util from 'util'; import { isEqual, isEqualWith } from 'lodash'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { RawKibanaPrivileges } from '../../../../plugins/security/common/model'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/spaces/saved_objects.ts b/x-pack/test/api_integration/apis/spaces/saved_objects.ts index b520c374d4f903..20fc3428bb2b16 100644 --- a/x-pack/test/api_integration/apis/spaces/saved_objects.ts +++ b/x-pack/test/api_integration/apis/spaces/saved_objects.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index b692699182cac0..0722edbcb45b3e 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import semver from 'semver'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { setupFleetAndAgents } from './services'; diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index 2f4575d45cc20f..a5388aa829d01c 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import testSubjSelector from '@kbn/test-subj-selector'; import { FtrProviderContext } from '../ftr_provider_context'; diff --git a/x-pack/test/functional/services/uptime/monitor.ts b/x-pack/test/functional/services/uptime/monitor.ts index 417c9bb20f9b7f..3b22a5f7f6630b 100644 --- a/x-pack/test/functional/services/uptime/monitor.ts +++ b/x-pack/test/functional/services/uptime/monitor.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeMonitorProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts index 58bac6fe454179..f2497041094f74 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts @@ -7,7 +7,7 @@ import { merge, omit, chunk, isEmpty } from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; import { IEvent } from '../../../../plugins/event_log/server'; diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts index f9f518091847de..170b01a01edf92 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { IEvent } from '../../../../plugins/event_log/server'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index 37aff5a8cdadd2..1ba8ea32b99224 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -6,7 +6,7 @@ */ import { SuperTest } from 'supertest'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; diff --git a/x-pack/test/security_api_integration/tests/session_idle/extension.ts b/x-pack/test/security_api_integration/tests/session_idle/extension.ts index 71621f4e3db8ab..b8fef972f05d6b 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/extension.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/extension.ts @@ -6,7 +6,7 @@ */ import { Cookie, cookie } from 'request'; -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 13a19e55ab5887..da339f54d41f46 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteAllDocsFromMetadataCurrentIndex, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts index f3f86d4610d2bb..1e1322944153bd 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteMetadataStream } from './data_stream_helper'; import { METADATA_REQUEST_V1_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts index 79ea93da8a5b09..318e857bdcad0b 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect.js'; +import expect from '@kbn/expect/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deletePolicyStream } from './data_stream_helper'; diff --git a/yarn.lock b/yarn.lock index 8b472845169785..7f2b44b5d0c3fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1392,7 +1392,7 @@ utility-types "^3.10.0" uuid "^3.3.2" -"@elastic/datemath@link:bazel-bin/packages/elastic-datemath/npm_module": +"@elastic/datemath@link:bazel-bin/packages/elastic-datemath": version "0.0.0" uid "" @@ -1435,7 +1435,7 @@ semver "7.3.2" topojson-client "^3.1.0" -"@elastic/eslint-config-kibana@link:bazel-bin/packages/elastic-eslint-config-kibana/npm_module": +"@elastic/eslint-config-kibana@link:bazel-bin/packages/elastic-eslint-config-kibana": version "0.0.0" uid "" @@ -1572,7 +1572,7 @@ "@types/node-jose" "1.1.0" node-jose "1.1.0" -"@elastic/safer-lodash-set@link:bazel-bin/packages/elastic-safer-lodash-set/npm_module": +"@elastic/safer-lodash-set@link:bazel-bin/packages/elastic-safer-lodash-set": version "0.0.0" uid "" @@ -2591,27 +2591,27 @@ "@babel/runtime" "^7.7.2" regenerator-runtime "^0.13.3" -"@kbn/ace@link:bazel-bin/packages/kbn-ace/npm_module": +"@kbn/ace@link:bazel-bin/packages/kbn-ace": version "0.0.0" uid "" -"@kbn/analytics@link:bazel-bin/packages/kbn-analytics/npm_module": +"@kbn/analytics@link:bazel-bin/packages/kbn-analytics": version "0.0.0" uid "" -"@kbn/apm-config-loader@link:bazel-bin/packages/kbn-apm-config-loader/npm_module": +"@kbn/apm-config-loader@link:bazel-bin/packages/kbn-apm-config-loader": version "0.0.0" uid "" -"@kbn/apm-utils@link:bazel-bin/packages/kbn-apm-utils/npm_module": +"@kbn/apm-utils@link:bazel-bin/packages/kbn-apm-utils": version "0.0.0" uid "" -"@kbn/babel-code-parser@link:bazel-bin/packages/kbn-babel-code-parser/npm_module": +"@kbn/babel-code-parser@link:bazel-bin/packages/kbn-babel-code-parser": version "0.0.0" uid "" -"@kbn/babel-preset@link:bazel-bin/packages/kbn-babel-preset/npm_module": +"@kbn/babel-preset@link:bazel-bin/packages/kbn-babel-preset": version "0.0.0" uid "" @@ -2619,23 +2619,23 @@ version "0.0.0" uid "" -"@kbn/config-schema@link:bazel-bin/packages/kbn-config-schema/npm_module": +"@kbn/config-schema@link:bazel-bin/packages/kbn-config-schema": version "0.0.0" uid "" -"@kbn/config@link:bazel-bin/packages/kbn-config/npm_module": +"@kbn/config@link:bazel-bin/packages/kbn-config": version "0.0.0" uid "" -"@kbn/crypto@link:bazel-bin/packages/kbn-crypto/npm_module": +"@kbn/crypto@link:bazel-bin/packages/kbn-crypto": version "0.0.0" uid "" -"@kbn/dev-utils@link:bazel-bin/packages/kbn-dev-utils/npm_module": +"@kbn/dev-utils@link:bazel-bin/packages/kbn-dev-utils": version "0.0.0" uid "" -"@kbn/docs-utils@link:bazel-bin/packages/kbn-docs-utils/npm_module": +"@kbn/docs-utils@link:bazel-bin/packages/kbn-docs-utils": version "0.0.0" uid "" @@ -2643,23 +2643,23 @@ version "0.0.0" uid "" -"@kbn/es@link:bazel-bin/packages/kbn-es/npm_module": +"@kbn/es@link:bazel-bin/packages/kbn-es": version "0.0.0" uid "" -"@kbn/eslint-import-resolver-kibana@link:bazel-bin/packages/kbn-eslint-import-resolver-kibana/npm_module": +"@kbn/eslint-import-resolver-kibana@link:bazel-bin/packages/kbn-eslint-import-resolver-kibana": version "0.0.0" uid "" -"@kbn/eslint-plugin-eslint@link:bazel-bin/packages/kbn-eslint-plugin-eslint/npm_module": +"@kbn/eslint-plugin-eslint@link:bazel-bin/packages/kbn-eslint-plugin-eslint": version "0.0.0" uid "" -"@kbn/expect@link:bazel-bin/packages/kbn-expect/npm_module": +"@kbn/expect@link:bazel-bin/packages/kbn-expect": version "0.0.0" uid "" -"@kbn/i18n@link:bazel-bin/packages/kbn-i18n/npm_module": +"@kbn/i18n@link:bazel-bin/packages/kbn-i18n": version "0.0.0" uid "" @@ -2667,23 +2667,23 @@ version "0.0.0" uid "" -"@kbn/io-ts-utils@link:bazel-bin/packages/kbn-io-ts-utils/npm_module": +"@kbn/io-ts-utils@link:bazel-bin/packages/kbn-io-ts-utils": version "0.0.0" uid "" -"@kbn/legacy-logging@link:bazel-bin/packages/kbn-legacy-logging/npm_module": +"@kbn/legacy-logging@link:bazel-bin/packages/kbn-legacy-logging": version "0.0.0" uid "" -"@kbn/logging@link:bazel-bin/packages/kbn-logging/npm_module": +"@kbn/logging@link:bazel-bin/packages/kbn-logging": version "0.0.0" uid "" -"@kbn/mapbox-gl@link:bazel-bin/packages/kbn-mapbox-gl/npm_module": +"@kbn/mapbox-gl@link:bazel-bin/packages/kbn-mapbox-gl": version "0.0.0" uid "" -"@kbn/monaco@link:bazel-bin/packages/kbn-monaco/npm_module": +"@kbn/monaco@link:bazel-bin/packages/kbn-monaco": version "0.0.0" uid "" @@ -2691,7 +2691,7 @@ version "0.0.0" uid "" -"@kbn/plugin-generator@link:bazel-bin/packages/kbn-plugin-generator/npm_module": +"@kbn/plugin-generator@link:bazel-bin/packages/kbn-plugin-generator": version "0.0.0" uid "" @@ -2707,47 +2707,47 @@ version "0.0.0" uid "" -"@kbn/securitysolution-es-utils@link:bazel-bin/packages/kbn-securitysolution-es-utils/npm_module": +"@kbn/securitysolution-es-utils@link:bazel-bin/packages/kbn-securitysolution-es-utils": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-alerting-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types/npm_module": +"@kbn/securitysolution-io-ts-alerting-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-list-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types/npm_module": +"@kbn/securitysolution-io-ts-list-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-list-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module": +"@kbn/securitysolution-io-ts-types@link:bazel-bin/packages/kbn-securitysolution-io-ts-types": version "0.0.0" uid "" -"@kbn/securitysolution-io-ts-utils@link:bazel-bin/packages/kbn-securitysolution-io-ts-utils/npm_module": +"@kbn/securitysolution-io-ts-utils@link:bazel-bin/packages/kbn-securitysolution-io-ts-utils": version "0.0.0" uid "" -"@kbn/securitysolution-list-api@link:bazel-bin/packages/kbn-securitysolution-list-api/npm_module": +"@kbn/securitysolution-list-api@link:bazel-bin/packages/kbn-securitysolution-list-api": version "0.0.0" uid "" -"@kbn/securitysolution-list-constants@link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module": +"@kbn/securitysolution-list-constants@link:bazel-bin/packages/kbn-securitysolution-list-constants": version "0.0.0" uid "" -"@kbn/securitysolution-list-hooks@link:bazel-bin/packages/kbn-securitysolution-list-hooks/npm_module": +"@kbn/securitysolution-list-hooks@link:bazel-bin/packages/kbn-securitysolution-list-hooks": version "0.0.0" uid "" -"@kbn/securitysolution-list-utils@link:bazel-bin/packages/kbn-securitysolution-list-utils/npm_module": +"@kbn/securitysolution-list-utils@link:bazel-bin/packages/kbn-securitysolution-list-utils": version "0.0.0" uid "" -"@kbn/securitysolution-utils@link:bazel-bin/packages/kbn-securitysolution-utils/npm_module": +"@kbn/securitysolution-utils@link:bazel-bin/packages/kbn-securitysolution-utils": version "0.0.0" uid "" -"@kbn/server-http-tools@link:bazel-bin/packages/kbn-server-http-tools/npm_module": +"@kbn/server-http-tools@link:bazel-bin/packages/kbn-server-http-tools": version "0.0.0" uid "" @@ -2755,7 +2755,7 @@ version "0.0.0" uid "" -"@kbn/std@link:bazel-bin/packages/kbn-std/npm_module": +"@kbn/std@link:bazel-bin/packages/kbn-std": version "0.0.0" uid "" @@ -2763,7 +2763,7 @@ version "0.0.0" uid "" -"@kbn/telemetry-tools@link:bazel-bin/packages/kbn-telemetry-tools/npm_module": +"@kbn/telemetry-tools@link:bazel-bin/packages/kbn-telemetry-tools": version "0.0.0" uid "" @@ -2775,7 +2775,7 @@ version "0.0.0" uid "" -"@kbn/tinymath@link:bazel-bin/packages/kbn-tinymath/npm_module": +"@kbn/tinymath@link:bazel-bin/packages/kbn-tinymath": version "0.0.0" uid "" @@ -2787,11 +2787,11 @@ version "0.0.0" uid "" -"@kbn/utility-types@link:bazel-bin/packages/kbn-utility-types/npm_module": +"@kbn/utility-types@link:bazel-bin/packages/kbn-utility-types": version "0.0.0" uid "" -"@kbn/utils@link:bazel-bin/packages/kbn-utils/npm_module": +"@kbn/utils@link:bazel-bin/packages/kbn-utils": version "0.0.0" uid "" From 843a81ea5182ae51824c7fb0420040248612ac30 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Thu, 3 Jun 2021 09:54:37 -0700 Subject: [PATCH 49/77] Splits migrationsv2 actions and unit tests into separate files (#101200) * Splits migrationsv2 actions and unit tests into separate files * Moves actions integration tests --- ...lk_overwrite_transformed_documents.test.ts | 46 + .../bulk_overwrite_transformed_documents.ts | 84 ++ .../migrationsv2/actions/clone_index.test.ts | 60 + .../migrationsv2/actions/clone_index.ts | 141 ++ .../migrationsv2/actions/close_pit.test.ts | 40 + .../migrationsv2/actions/close_pit.ts | 41 + .../migrationsv2/actions/constants.ts | 20 + .../migrationsv2/actions/create_index.test.ts | 59 + .../migrationsv2/actions/create_index.ts | 145 ++ .../actions/fetch_indices.test.ts | 37 + .../migrationsv2/actions/fetch_indices.ts | 49 + .../migrationsv2/actions/index.test.ts | 346 ----- .../migrationsv2/actions/index.ts | 1267 ++--------------- .../integration_tests/actions.test.ts | 14 +- .../migrationsv2/actions/open_pit.test.ts | 40 + .../migrationsv2/actions/open_pit.ts | 43 + .../actions/pickup_updated_mappings.test.ts | 39 + .../actions/pickup_updated_mappings.ts | 57 + .../actions/read_with_pit.test.ts | 45 + .../migrationsv2/actions/read_with_pit.ts | 92 ++ .../actions/refresh_index.test.ts | 42 + .../migrationsv2/actions/refresh_index.ts | 40 + .../migrationsv2/actions/reindex.test.ts | 48 + .../migrationsv2/actions/reindex.ts | 90 ++ .../actions/remove_write_block.test.ts | 53 + .../actions/remove_write_block.ts | 60 + .../search_for_outdated_documents.test.ts | 69 + .../actions/search_for_outdated_documents.ts | 77 + .../actions/set_write_block.test.ts | 52 + .../migrationsv2/actions/set_write_block.ts | 73 + .../migrationsv2/actions/transform_docs.ts | 30 + .../actions/update_aliases.test.ts | 55 + .../migrationsv2/actions/update_aliases.ts | 98 ++ .../update_and_pickup_mappings.test.ts | 45 + .../actions/update_and_pickup_mappings.ts | 80 ++ .../migrationsv2/actions/verify_reindex.ts | 52 + .../wait_for_index_status_yellow.test.ts | 44 + .../actions/wait_for_index_status_yellow.ts | 45 + ...t_for_pickup_updated_mappings_task.test.ts | 59 + .../wait_for_pickup_updated_mappings_task.ts | 43 + .../actions/wait_for_reindex_task.test.ts | 56 + .../actions/wait_for_reindex_task.ts | 65 + .../actions/wait_for_task.test.ts | 47 + .../migrationsv2/actions/wait_for_task.ts | 95 ++ 44 files changed, 2544 insertions(+), 1539 deletions(-) create mode 100644 src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/clone_index.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/close_pit.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/constants.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/create_index.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts delete mode 100644 src/core/server/saved_objects/migrationsv2/actions/index.test.ts rename src/core/server/saved_objects/migrationsv2/{ => actions}/integration_tests/actions.test.ts (99%) create mode 100644 src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/open_pit.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/reindex.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts create mode 100644 src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts new file mode 100644 index 00000000000000..8ff9591798fd4a --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.test.ts @@ -0,0 +1,46 @@ +/* + * 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { bulkOverwriteTransformedDocuments } from './bulk_overwrite_transformed_documents'; + +describe('bulkOverwriteTransformedDocuments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = bulkOverwriteTransformedDocuments({ + client, + index: 'new_index', + transformedDocs: [], + refresh: 'wait_for', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts new file mode 100644 index 00000000000000..830a8efccc7eba --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/bulk_overwrite_transformed_documents.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE } from './constants'; + +/** @internal */ +export interface BulkOverwriteTransformedDocumentsParams { + client: ElasticsearchClient; + index: string; + transformedDocs: SavedObjectsRawDoc[]; + refresh?: estypes.Refresh; +} +/** + * Write the up-to-date transformed documents to the index, overwriting any + * documents that are still on their outdated version. + */ +export const bulkOverwriteTransformedDocuments = ({ + client, + index, + transformedDocs, + refresh = false, +}: BulkOverwriteTransformedDocumentsParams): TaskEither.TaskEither< + RetryableEsClientError, + 'bulk_index_succeeded' +> => () => { + return client + .bulk({ + // Because we only add aliases in the MARK_VERSION_INDEX_READY step we + // can't bulkIndex to an alias with require_alias=true. This means if + // users tamper during this operation (delete indices or restore a + // snapshot), we could end up auto-creating an index without the correct + // mappings. Such tampering could lead to many other problems and is + // probably unlikely so for now we'll accept this risk and wait till + // system indices puts in place a hard control. + require_alias: false, + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + refresh, + filter_path: ['items.*.error'], + body: transformedDocs.flatMap((doc) => { + return [ + { + index: { + _index: index, + _id: doc._id, + // overwrite existing documents + op_type: 'index', + // use optimistic concurrency control to ensure that outdated + // documents are only overwritten once with the latest version + if_seq_no: doc._seq_no, + if_primary_term: doc._primary_term, + }, + }, + doc._source, + ]; + }), + }) + .then((res) => { + // Filter out version_conflict_engine_exception since these just mean + // that another instance already updated these documents + const errors = (res.body.items ?? []).filter( + (item) => item.index?.error?.type !== 'version_conflict_engine_exception' + ); + if (errors.length === 0) { + return Either.right('bulk_index_succeeded' as const); + } else { + throw new Error(JSON.stringify(errors)); + } + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts new file mode 100644 index 00000000000000..84b4b00bc7e7fd --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/clone_index.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { errors as EsErrors } from '@elastic/elasticsearch'; +import { cloneIndex } from './clone_index'; +import { setWriteBlock } from './set_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('cloneIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = cloneIndex({ + client, + source: 'my_source_index', + target: 'my_target_index', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts b/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts new file mode 100644 index 00000000000000..5674535c803287 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/clone_index.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import type { IndexNotFound, AcknowledgeResponse } from './'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; +import { + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + INDEX_NUMBER_OF_SHARDS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; +export type CloneIndexResponse = AcknowledgeResponse; + +/** @internal */ +export interface CloneIndexParams { + client: ElasticsearchClient; + source: string; + target: string; + /** only used for testing */ + timeout?: string; +} +/** + * Makes a clone of the source index into the target. + * + * @remarks + * This method adds some additional logic to the ES clone index API: + * - it is idempotent, if it gets called multiple times subsequent calls will + * wait for the first clone operation to complete (up to 60s) + * - the first call will wait up to 120s for the cluster state and all shards + * to be updated. + */ +export const cloneIndex = ({ + client, + source, + target, + timeout = DEFAULT_TIMEOUT, +}: CloneIndexParams): TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound, + CloneIndexResponse +> => { + const cloneTask: TaskEither.TaskEither< + RetryableEsClientError | IndexNotFound, + AcknowledgeResponse + > = () => { + return client.indices + .clone( + { + index: source, + target, + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + body: { + settings: { + index: { + // The source we're cloning from will have a write block set, so + // we need to remove it to allow writes to our newly cloned index + 'blocks.write': false, + number_of_shards: INDEX_NUMBER_OF_SHARDS, + auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, + // Set an explicit refresh interval so that we don't inherit the + // value from incorrectly configured index templates (not required + // after we adopt system indices) + refresh_interval: '1s', + // Bump priority so that recovery happens before newer indices + priority: 10, + }, + }, + }, + timeout, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + /** + * - acknowledged=false, we timed out before the cluster state was + * updated with the newly created index, but it probably will be + * created sometime soon. + * - shards_acknowledged=false, we timed out before all shards were + * started + * - acknowledged=true, shards_acknowledged=true, cloning complete + */ + return Either.right({ + acknowledged: res.body.acknowledged, + shardsAcknowledged: res.body.shards_acknowledged, + }); + }) + .catch((error: EsErrors.ResponseError) => { + if (error?.body?.error?.type === 'index_not_found_exception') { + return Either.left({ + type: 'index_not_found_exception' as const, + index: error.body.error.index, + }); + } else if (error?.body?.error?.type === 'resource_already_exists_exception') { + /** + * If the target index already exists it means a previous clone + * operation had already been started. However, we can't be sure + * that all shards were started so return shardsAcknowledged: false + */ + return Either.right({ + acknowledged: true, + shardsAcknowledged: false, + }); + } else { + throw error; + } + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + cloneTask, + TaskEither.chain((res) => { + if (res.acknowledged && res.shardsAcknowledged) { + // If the cluster state was updated and all shards ackd we're done + return TaskEither.right(res); + } else { + // Otherwise, wait until the target index has a 'green' status. + return pipe( + waitForIndexStatusYellow({ client, index: target, timeout }), + TaskEither.map((value) => { + /** When the index status is 'green' we know that all shards were started */ + return { acknowledged: true, shardsAcknowledged: true }; + }) + ); + } + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.ts new file mode 100644 index 00000000000000..5d9696239a61e3 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/close_pit.test.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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { closePit } from './close_pit'; + +describe('closePit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = closePit({ client, pitId: 'pitId' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts new file mode 100644 index 00000000000000..d421950c839e23 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/close_pit.ts @@ -0,0 +1,41 @@ +/* + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface ClosePitParams { + client: ElasticsearchClient; + pitId: string; +} +/* + * Closes PIT. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const closePit = ({ + client, + pitId, +}: ClosePitParams): TaskEither.TaskEither => () => { + return client + .closePointInTime({ + body: { id: pitId }, + }) + .then((response) => { + if (!response.body.succeeded) { + throw new Error(`Failed to close PointInTime with id: ${pitId}`); + } + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/constants.ts b/src/core/server/saved_objects/migrationsv2/actions/constants.ts new file mode 100644 index 00000000000000..5d0d2ffe5d695b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/constants.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. + */ + +/** + * Batch size for updateByQuery and reindex operations. + * Uses the default value of 1000 for Elasticsearch reindex operation. + */ +export const BATCH_SIZE = 1_000; +export const DEFAULT_TIMEOUT = '60s'; +/** Allocate 1 replica if there are enough data nodes, otherwise continue with 0 */ +export const INDEX_AUTO_EXPAND_REPLICAS = '0-1'; +/** ES rule of thumb: shards should be several GB to 10's of GB, so Kibana is unlikely to cross that limit */ +export const INDEX_NUMBER_OF_SHARDS = 1; +/** Wait for all shards to be active before starting an operation */ +export const WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE = 'all'; diff --git a/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts new file mode 100644 index 00000000000000..d5d906898943cc --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/create_index.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { createIndex } from './create_index'; +import { setWriteBlock } from './set_write_block'; + +describe('createIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = createIndex({ + client, + indexName: 'new_index', + mappings: { properties: {} }, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/create_index.ts b/src/core/server/saved_objects/migrationsv2/actions/create_index.ts new file mode 100644 index 00000000000000..47ee44e762db79 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/create_index.ts @@ -0,0 +1,145 @@ +/* + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/pipeable'; +import type { estypes } from '@elastic/elasticsearch'; +import { AcknowledgeResponse } from './index'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { IndexMapping } from '../../mappings'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; + +function aliasArrayToRecord(aliases: string[]): Record { + const result: Record = {}; + for (const alias of aliases) { + result[alias] = {}; + } + return result; +} + +/** @internal */ +export interface CreateIndexParams { + client: ElasticsearchClient; + indexName: string; + mappings: IndexMapping; + aliases?: string[]; +} +/** + * Creates an index with the given mappings + * + * @remarks + * This method adds some additional logic to the ES create index API: + * - it is idempotent, if it gets called multiple times subsequent calls will + * wait for the first create operation to complete (up to 60s) + * - the first call will wait up to 120s for the cluster state and all shards + * to be updated. + */ +export const createIndex = ({ + client, + indexName, + mappings, + aliases = [], +}: CreateIndexParams): TaskEither.TaskEither => { + const createIndexTask: TaskEither.TaskEither< + RetryableEsClientError, + AcknowledgeResponse + > = () => { + const aliasesObject = aliasArrayToRecord(aliases); + + return client.indices + .create( + { + index: indexName, + // wait until all shards are available before creating the index + // (since number_of_shards=1 this does not have any effect atm) + wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, + // Wait up to 60s for the cluster state to update and all shards to be + // started + timeout: DEFAULT_TIMEOUT, + body: { + mappings, + aliases: aliasesObject, + settings: { + index: { + // ES rule of thumb: shards should be several GB to 10's of GB, so + // Kibana is unlikely to cross that limit. + number_of_shards: 1, + auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, + // Set an explicit refresh interval so that we don't inherit the + // value from incorrectly configured index templates (not required + // after we adopt system indices) + refresh_interval: '1s', + // Bump priority so that recovery happens before newer indices + priority: 10, + }, + }, + }, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + /** + * - acknowledged=false, we timed out before the cluster state was + * updated on all nodes with the newly created index, but it + * probably will be created sometime soon. + * - shards_acknowledged=false, we timed out before all shards were + * started + * - acknowledged=true, shards_acknowledged=true, index creation complete + */ + return Either.right({ + acknowledged: res.body.acknowledged, + shardsAcknowledged: res.body.shards_acknowledged, + }); + }) + .catch((error) => { + if (error?.body?.error?.type === 'resource_already_exists_exception') { + /** + * If the target index already exists it means a previous create + * operation had already been started. However, we can't be sure + * that all shards were started so return shardsAcknowledged: false + */ + return Either.right({ + acknowledged: true, + shardsAcknowledged: false, + }); + } else { + throw error; + } + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + createIndexTask, + TaskEither.chain((res) => { + if (res.acknowledged && res.shardsAcknowledged) { + // If the cluster state was updated and all shards ackd we're done + return TaskEither.right('create_index_succeeded'); + } else { + // Otherwise, wait until the target index has a 'yellow' status. + return pipe( + waitForIndexStatusYellow({ client, index: indexName, timeout: DEFAULT_TIMEOUT }), + TaskEither.map(() => { + /** When the index status is 'yellow' we know that all shards were started */ + return 'create_index_succeeded'; + }) + ); + } + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts new file mode 100644 index 00000000000000..0dab1728b6ef2d --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { fetchIndices } from './fetch_indices'; + +describe('fetchIndices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = fetchIndices({ client, indices: ['my_index'] }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts new file mode 100644 index 00000000000000..3847252eb6db15 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/fetch_indices.ts @@ -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 * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Either from 'fp-ts/lib/Either'; +import { IndexMapping } from '../../mappings'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +export type FetchIndexResponse = Record< + string, + { aliases: Record; mappings: IndexMapping; settings: unknown } +>; + +/** @internal */ +export interface FetchIndicesParams { + client: ElasticsearchClient; + indices: string[]; +} + +/** + * Fetches information about the given indices including aliases, mappings and + * settings. + */ +export const fetchIndices = ({ + client, + indices, +}: FetchIndicesParams): TaskEither.TaskEither => + // @ts-expect-error @elastic/elasticsearch IndexState.alias and IndexState.mappings should be required + () => { + return client.indices + .get( + { + index: indices, + ignore_unavailable: true, // Don't return an error for missing indices. Note this *will* include closed indices, the docs are misleading https://github.com/elastic/elasticsearch/issues/63607 + }, + { ignore: [404], maxRetries: 0 } + ) + .then(({ body }) => { + return Either.right(body); + }) + .catch(catchRetryableEsClientErrors); + }; diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/index.test.ts deleted file mode 100644 index 05da335d708840..00000000000000 --- a/src/core/server/saved_objects/migrationsv2/actions/index.test.ts +++ /dev/null @@ -1,346 +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 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 * as Actions from './'; -import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; -import { errors as EsErrors } from '@elastic/elasticsearch'; -jest.mock('./catch_retryable_es_client_errors'); -import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; -import * as Option from 'fp-ts/lib/Option'; - -describe('actions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - // Create a mock client that rejects all methods with a 503 status code - // response. - const retryableError = new EsErrors.ResponseError( - elasticsearchClientMock.createApiResponse({ - statusCode: 503, - body: { error: { type: 'es_type', reason: 'es_reason' } }, - }) - ); - const client = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) - ); - - const nonRetryableError = new Error('crash'); - const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) - ); - - describe('fetchIndices', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.fetchIndices({ client, indices: ['my_index'] }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('setWriteBlock', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.setWriteBlock({ client, index: 'my_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('cloneIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.cloneIndex({ - client, - source: 'my_source_index', - target: 'my_target_index', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('pickupUpdatedMappings', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.pickupUpdatedMappings(client, 'my_index'); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('openPit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.openPit({ client, index: 'my_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('readWithPit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.readWithPit({ - client, - pitId: 'pitId', - query: { match_all: {} }, - batchSize: 10_000, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('closePit', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.closePit({ client, pitId: 'pitId' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('reindex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.reindex({ - client, - sourceIndex: 'my_source_index', - targetIndex: 'my_target_index', - reindexScript: Option.none, - requireAlias: false, - unusedTypesQuery: {}, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('waitForReindexTask', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.waitForReindexTask({ client, taskId: 'my task id', timeout: '60s' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('waitForPickupUpdatedMappingsTask', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.waitForPickupUpdatedMappingsTask({ - client, - taskId: 'my task id', - timeout: '60s', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('updateAliases', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.updateAliases({ client, aliasActions: [] }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('createIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.createIndex({ - client, - indexName: 'new_index', - mappings: { properties: {} }, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - it('re-throws non retry-able errors', async () => { - const task = Actions.setWriteBlock({ - client: clientWithNonRetryableError, - index: 'my_index', - }); - await task(); - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); - }); - }); - - describe('updateAndPickupMappings', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.updateAndPickupMappings({ - client, - index: 'new_index', - mappings: { properties: {} }, - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('searchForOutdatedDocuments', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.searchForOutdatedDocuments(client, { - batchSize: 1000, - targetIndex: 'new_index', - outdatedDocumentsQuery: {}, - }); - - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - - it('configures request according to given parameters', async () => { - const esClient = elasticsearchClientMock.createInternalClient(); - const query = {}; - const targetIndex = 'new_index'; - const batchSize = 1000; - const task = Actions.searchForOutdatedDocuments(esClient, { - batchSize, - targetIndex, - outdatedDocumentsQuery: query, - }); - - await task(); - - expect(esClient.search).toHaveBeenCalledTimes(1); - expect(esClient.search).toHaveBeenCalledWith( - expect.objectContaining({ - index: targetIndex, - size: batchSize, - body: expect.objectContaining({ query }), - }) - ); - }); - }); - - describe('bulkOverwriteTransformedDocuments', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.bulkOverwriteTransformedDocuments({ - client, - index: 'new_index', - transformedDocs: [], - refresh: 'wait_for', - }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); - - describe('refreshIndex', () => { - it('calls catchRetryableEsClientErrors when the promise rejects', async () => { - const task = Actions.refreshIndex({ client, targetIndex: 'target_index' }); - try { - await task(); - } catch (e) { - /** ignore */ - } - - expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); - }); - }); -}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/index.ts b/src/core/server/saved_objects/migrationsv2/actions/index.ts index 905d64947298ec..98d7167ffc31a1 100644 --- a/src/core/server/saved_objects/migrationsv2/actions/index.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/index.ts @@ -6,1231 +6,126 @@ * Side Public License, v 1. */ -import * as Either from 'fp-ts/lib/Either'; -import * as TaskEither from 'fp-ts/lib/TaskEither'; -import * as Option from 'fp-ts/lib/Option'; -import type { estypes } from '@elastic/elasticsearch'; -import { errors as EsErrors } from '@elastic/elasticsearch'; -import type { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { flow } from 'fp-ts/lib/function'; -import { ElasticsearchClient } from '../../../elasticsearch'; -import { IndexMapping } from '../../mappings'; -import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; -import type { TransformRawDocs } from '../types'; -import { - catchRetryableEsClientErrors, - RetryableEsClientError, -} from './catch_retryable_es_client_errors'; -import { - DocumentsTransformFailed, - DocumentsTransformSuccess, -} from '../../migrations/core/migrate_raw_docs'; -export type { RetryableEsClientError }; - -/** - * Batch size for updateByQuery and reindex operations. - * Uses the default value of 1000 for Elasticsearch reindex operation. - */ -const BATCH_SIZE = 1_000; -const DEFAULT_TIMEOUT = '60s'; -/** Allocate 1 replica if there are enough data nodes, otherwise continue with 0 */ -const INDEX_AUTO_EXPAND_REPLICAS = '0-1'; -/** ES rule of thumb: shards should be several GB to 10's of GB, so Kibana is unlikely to cross that limit */ -const INDEX_NUMBER_OF_SHARDS = 1; -/** Wait for all shards to be active before starting an operation */ -const WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE = 'all'; - -// Map of left response 'type' string -> response interface -export interface ActionErrorTypeMap { - wait_for_task_completion_timeout: WaitForTaskCompletionTimeout; - retryable_es_client_error: RetryableEsClientError; - index_not_found_exception: IndexNotFound; - target_index_had_write_block: TargetIndexHadWriteBlock; - incompatible_mapping_exception: IncompatibleMappingException; - alias_not_found_exception: AliasNotFound; - remove_index_not_a_concrete_index: RemoveIndexNotAConcreteIndex; - documents_transform_failed: DocumentsTransformFailed; -} - -/** - * Type guard for narrowing the type of a left - */ -export function isLeftTypeof( - res: any, - typeString: T -): res is ActionErrorTypeMap[T] { - return res.type === typeString; -} - -export type FetchIndexResponse = Record< - string, - { aliases: Record; mappings: IndexMapping; settings: unknown } ->; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; +import { DocumentsTransformFailed } from '../../migrations/core/migrate_raw_docs'; -/** @internal */ -export interface FetchIndicesParams { - client: ElasticsearchClient; - indices: string[]; -} - -/** - * Fetches information about the given indices including aliases, mappings and - * settings. - */ -export const fetchIndices = ({ - client, - indices, -}: FetchIndicesParams): TaskEither.TaskEither => - // @ts-expect-error @elastic/elasticsearch IndexState.alias and IndexState.mappings should be required - () => { - return client.indices - .get( - { - index: indices, - ignore_unavailable: true, // Don't return an error for missing indices. Note this *will* include closed indices, the docs are misleading https://github.com/elastic/elasticsearch/issues/63607 - }, - { ignore: [404], maxRetries: 0 } - ) - .then(({ body }) => { - return Either.right(body); - }) - .catch(catchRetryableEsClientErrors); - }; +export { + BATCH_SIZE, + DEFAULT_TIMEOUT, + INDEX_AUTO_EXPAND_REPLICAS, + INDEX_NUMBER_OF_SHARDS, + WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, +} from './constants'; -export interface IndexNotFound { - type: 'index_not_found_exception'; - index: string; -} +export type { RetryableEsClientError }; -/** @internal */ -export interface SetWriteBlockParams { - client: ElasticsearchClient; - index: string; -} -/** - * Sets a write block in place for the given index. If the response includes - * `acknowledged: true` all in-progress writes have drained and no further - * writes to this index will be possible. - * - * The first time the write block is added to an index the response will - * include `shards_acknowledged: true` but once the block is in place, - * subsequent calls return `shards_acknowledged: false` - */ -export const setWriteBlock = ({ - client, - index, -}: SetWriteBlockParams): TaskEither.TaskEither< - IndexNotFound | RetryableEsClientError, - 'set_write_block_succeeded' -> => () => { - return ( - client.indices - .addBlock<{ - acknowledged: boolean; - shards_acknowledged: boolean; - }>( - { - index, - block: 'write', - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - // not typed yet - .then((res: any) => { - return res.body.acknowledged === true - ? Either.right('set_write_block_succeeded' as const) - : Either.left({ - type: 'retryable_es_client_error' as const, - message: 'set_write_block_failed', - }); - }) - .catch((e: ElasticsearchClientError) => { - if (e instanceof EsErrors.ResponseError) { - if (e.body?.error?.type === 'index_not_found_exception') { - return Either.left({ type: 'index_not_found_exception' as const, index }); - } - } - throw e; - }) - .catch(catchRetryableEsClientErrors) - ); -}; +// actions/* imports +export type { FetchIndexResponse, FetchIndicesParams } from './fetch_indices'; +export { fetchIndices } from './fetch_indices'; -/** @internal */ -export interface RemoveWriteBlockParams { - client: ElasticsearchClient; - index: string; -} -/** - * Removes a write block from an index - */ -export const removeWriteBlock = ({ - client, - index, -}: RemoveWriteBlockParams): TaskEither.TaskEither< - RetryableEsClientError, - 'remove_write_block_succeeded' -> => () => { - return client.indices - .putSettings<{ - acknowledged: boolean; - shards_acknowledged: boolean; - }>( - { - index, - // Don't change any existing settings - preserve_existing: true, - body: { - index: { - blocks: { - write: false, - }, - }, - }, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - return res.body.acknowledged === true - ? Either.right('remove_write_block_succeeded' as const) - : Either.left({ - type: 'retryable_es_client_error' as const, - message: 'remove_write_block_failed', - }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { SetWriteBlockParams } from './set_write_block'; +export { setWriteBlock } from './set_write_block'; -/** @internal */ -export interface WaitForIndexStatusYellowParams { - client: ElasticsearchClient; - index: string; - timeout?: string; -} -/** - * A yellow index status means the index's primary shard is allocated and the - * index is ready for searching/indexing documents, but ES wasn't able to - * allocate the replicas. When migrations proceed with a yellow index it means - * we don't have as much data-redundancy as we could have, but waiting for - * replicas would mean that v2 migrations fail where v1 migrations would have - * succeeded. It doesn't feel like it's Kibana's job to force users to keep - * their clusters green and even if it's green when we migrate it can turn - * yellow at any point in the future. So ultimately data-redundancy is up to - * users to maintain. - */ -export const waitForIndexStatusYellow = ({ - client, - index, - timeout = DEFAULT_TIMEOUT, -}: WaitForIndexStatusYellowParams): TaskEither.TaskEither => () => { - return client.cluster - .health({ index, wait_for_status: 'yellow', timeout }) - .then(() => { - return Either.right({}); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { RemoveWriteBlockParams } from './remove_write_block'; +export { removeWriteBlock } from './remove_write_block'; -export type CloneIndexResponse = AcknowledgeResponse; +export type { CloneIndexResponse, CloneIndexParams } from './clone_index'; +export { cloneIndex } from './clone_index'; -/** @internal */ -export interface CloneIndexParams { - client: ElasticsearchClient; - source: string; - target: string; - /** only used for testing */ - timeout?: string; -} -/** - * Makes a clone of the source index into the target. - * - * @remarks - * This method adds some additional logic to the ES clone index API: - * - it is idempotent, if it gets called multiple times subsequent calls will - * wait for the first clone operation to complete (up to 60s) - * - the first call will wait up to 120s for the cluster state and all shards - * to be updated. - */ -export const cloneIndex = ({ - client, - source, - target, - timeout = DEFAULT_TIMEOUT, -}: CloneIndexParams): TaskEither.TaskEither< - RetryableEsClientError | IndexNotFound, - CloneIndexResponse -> => { - const cloneTask: TaskEither.TaskEither< - RetryableEsClientError | IndexNotFound, - AcknowledgeResponse - > = () => { - return client.indices - .clone( - { - index: source, - target, - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - body: { - settings: { - index: { - // The source we're cloning from will have a write block set, so - // we need to remove it to allow writes to our newly cloned index - 'blocks.write': false, - number_of_shards: INDEX_NUMBER_OF_SHARDS, - auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, - // Set an explicit refresh interval so that we don't inherit the - // value from incorrectly configured index templates (not required - // after we adopt system indices) - refresh_interval: '1s', - // Bump priority so that recovery happens before newer indices - priority: 10, - }, - }, - }, - timeout, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - /** - * - acknowledged=false, we timed out before the cluster state was - * updated with the newly created index, but it probably will be - * created sometime soon. - * - shards_acknowledged=false, we timed out before all shards were - * started - * - acknowledged=true, shards_acknowledged=true, cloning complete - */ - return Either.right({ - acknowledged: res.body.acknowledged, - shardsAcknowledged: res.body.shards_acknowledged, - }); - }) - .catch((error: EsErrors.ResponseError) => { - if (error?.body?.error?.type === 'index_not_found_exception') { - return Either.left({ - type: 'index_not_found_exception' as const, - index: error.body.error.index, - }); - } else if (error?.body?.error?.type === 'resource_already_exists_exception') { - /** - * If the target index already exists it means a previous clone - * operation had already been started. However, we can't be sure - * that all shards were started so return shardsAcknowledged: false - */ - return Either.right({ - acknowledged: true, - shardsAcknowledged: false, - }); - } else { - throw error; - } - }) - .catch(catchRetryableEsClientErrors); - }; +export type { WaitForIndexStatusYellowParams } from './wait_for_index_status_yellow'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; - return pipe( - cloneTask, - TaskEither.chain((res) => { - if (res.acknowledged && res.shardsAcknowledged) { - // If the cluster state was updated and all shards ackd we're done - return TaskEither.right(res); - } else { - // Otherwise, wait until the target index has a 'green' status. - return pipe( - waitForIndexStatusYellow({ client, index: target, timeout }), - TaskEither.map((value) => { - /** When the index status is 'green' we know that all shards were started */ - return { acknowledged: true, shardsAcknowledged: true }; - }) - ); - } - }) - ); -}; +export type { WaitForTaskResponse, WaitForTaskCompletionTimeout } from './wait_for_task'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; -interface WaitForTaskResponse { - error: Option.Option<{ type: string; reason: string; index: string }>; - completed: boolean; - failures: Option.Option; - description?: string; -} +export type { UpdateByQueryResponse } from './pickup_updated_mappings'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; -/** - * After waiting for the specificed timeout, the task has not yet completed. - * - * When querying the tasks API we use `wait_for_completion=true` to block the - * request until the task completes. If after the `timeout`, the task still has - * not completed we return this error. This does not mean that the task itelf - * has reached a timeout, Elasticsearch will continue to run the task. - */ -export interface WaitForTaskCompletionTimeout { - /** After waiting for the specificed timeout, the task has not yet completed. */ - readonly type: 'wait_for_task_completion_timeout'; - readonly message: string; - readonly error?: Error; -} +export type { OpenPitResponse, OpenPitParams } from './open_pit'; +export { openPit, pitKeepAlive } from './open_pit'; -const catchWaitForTaskCompletionTimeout = ( - e: ResponseError -): Either.Either => { - if ( - e.body?.error?.type === 'timeout_exception' || - e.body?.error?.type === 'receive_timeout_transport_exception' - ) { - return Either.left({ - type: 'wait_for_task_completion_timeout' as const, - message: `[${e.body.error.type}] ${e.body.error.reason}`, - error: e, - }); - } else { - throw e; - } -}; +export type { ReadWithPit, ReadWithPitParams } from './read_with_pit'; +export { readWithPit } from './read_with_pit'; -/** @internal */ -export interface WaitForTaskParams { - client: ElasticsearchClient; - taskId: string; - timeout: string; -} -/** - * Blocks for up to 60s or until a task completes. - * - * TODO: delete completed tasks - */ -const waitForTask = ({ - client, - taskId, - timeout, -}: WaitForTaskParams): TaskEither.TaskEither< - RetryableEsClientError | WaitForTaskCompletionTimeout, - WaitForTaskResponse -> => () => { - return client.tasks - .get({ - task_id: taskId, - wait_for_completion: true, - timeout, - }) - .then((res) => { - const body = res.body; - const failures = body.response?.failures ?? []; - return Either.right({ - completed: body.completed, - // @ts-expect-error @elastic/elasticsearch GetTaskResponse doesn't declare `error` property - error: Option.fromNullable(body.error), - failures: failures.length > 0 ? Option.some(failures) : Option.none, - description: body.task.description, - }); - }) - .catch(catchWaitForTaskCompletionTimeout) - .catch(catchRetryableEsClientErrors); -}; +export type { ClosePitParams } from './close_pit'; +export { closePit } from './close_pit'; -export interface UpdateByQueryResponse { - taskId: string; -} +export type { TransformDocsParams } from './transform_docs'; +export { transformDocs } from './transform_docs'; -/** - * Pickup updated mappings by performing an update by query operation on all - * documents in the index. Returns a task ID which can be - * tracked for progress. - * - * @remarks When mappings are updated to add a field which previously wasn't - * mapped Elasticsearch won't automatically add existing documents to it's - * internal search indices. So search results on this field won't return any - * existing documents. By running an update by query we essentially refresh - * these the internal search indices for all existing documents. - * This action uses `conflicts: 'proceed'` allowing several Kibana instances - * to run this in parallel. - */ -export const pickupUpdatedMappings = ( - client: ElasticsearchClient, - index: string -): TaskEither.TaskEither => () => { - return client - .updateByQuery({ - // Ignore version conflicts that can occur from parallel update by query operations - conflicts: 'proceed', - // Return an error when targeting missing or closed indices - allow_no_indices: false, - index, - // How many documents to update per batch - scroll_size: BATCH_SIZE, - // force a refresh so that we can query the updated index immediately - // after the operation completes - refresh: true, - // Create a task and return task id instead of blocking until complete - wait_for_completion: false, - }) - .then(({ body: { task: taskId } }) => { - return Either.right({ taskId: String(taskId!) }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { RefreshIndexParams } from './refresh_index'; +export { refreshIndex } from './refresh_index'; -/** @internal */ -export interface OpenPitResponse { - pitId: string; -} +export type { ReindexResponse, ReindexParams } from './reindex'; +export { reindex } from './reindex'; -/** @internal */ -export interface OpenPitParams { - client: ElasticsearchClient; - index: string; -} -// how long ES should keep PIT alive -const pitKeepAlive = '10m'; -/* - * Creates a lightweight view of data when the request has been initiated. - * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - * */ -export const openPit = ({ - client, - index, -}: OpenPitParams): TaskEither.TaskEither => () => { - return client - .openPointInTime({ - index, - keep_alive: pitKeepAlive, - }) - .then((response) => Either.right({ pitId: response.body.id })) - .catch(catchRetryableEsClientErrors); -}; +import type { IncompatibleMappingException } from './wait_for_reindex_task'; +export { waitForReindexTask } from './wait_for_reindex_task'; -/** @internal */ -export interface ReadWithPit { - outdatedDocuments: SavedObjectsRawDoc[]; - readonly lastHitSortValue: number[] | undefined; - readonly totalHits: number | undefined; -} +export type { VerifyReindexParams } from './verify_reindex'; +export { verifyReindex } from './verify_reindex'; -/** @internal */ +import type { AliasNotFound, RemoveIndexNotAConcreteIndex } from './update_aliases'; +export type { AliasAction, UpdateAliasesParams } from './update_aliases'; +export { updateAliases } from './update_aliases'; -export interface ReadWithPitParams { - client: ElasticsearchClient; - pitId: string; - query: estypes.QueryContainer; - batchSize: number; - searchAfter?: number[]; - seqNoPrimaryTerm?: boolean; -} +export type { CreateIndexParams } from './create_index'; +export { createIndex } from './create_index'; -/* - * Requests documents from the index using PIT mechanism. - * */ -export const readWithPit = ({ - client, - pitId, - query, - batchSize, - searchAfter, - seqNoPrimaryTerm, -}: ReadWithPitParams): TaskEither.TaskEither => () => { - return client - .search({ - seq_no_primary_term: seqNoPrimaryTerm, - body: { - // Sort fields are required to use searchAfter - sort: { - // the most efficient option as order is not important for the migration - _shard_doc: { order: 'asc' }, - }, - pit: { id: pitId, keep_alive: pitKeepAlive }, - size: batchSize, - search_after: searchAfter, - /** - * We want to know how many documents we need to process so we can log the progress. - * But we also want to increase the performance of these requests, - * so we ask ES to report the total count only on the first request (when searchAfter does not exist) - */ - track_total_hits: typeof searchAfter === 'undefined', - query, - }, - }) - .then((response) => { - const totalHits = - typeof response.body.hits.total === 'number' - ? response.body.hits.total // This format is to be removed in 8.0 - : response.body.hits.total?.value; - const hits = response.body.hits.hits; +export type { + UpdateAndPickupMappingsResponse, + UpdateAndPickupMappingsParams, +} from './update_and_pickup_mappings'; +export { updateAndPickupMappings } from './update_and_pickup_mappings'; - if (hits.length > 0) { - return Either.right({ - // @ts-expect-error @elastic/elasticsearch _source is optional - outdatedDocuments: hits as SavedObjectsRawDoc[], - lastHitSortValue: hits[hits.length - 1].sort as number[], - totalHits, - }); - } +export { waitForPickupUpdatedMappingsTask } from './wait_for_pickup_updated_mappings_task'; - return Either.right({ - outdatedDocuments: [], - lastHitSortValue: undefined, - totalHits, - }); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { + SearchResponse, + SearchForOutdatedDocumentsOptions, +} from './search_for_outdated_documents'; +export { searchForOutdatedDocuments } from './search_for_outdated_documents'; -/** @internal */ -export interface ClosePitParams { - client: ElasticsearchClient; - pitId: string; -} -/* - * Closes PIT. - * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - * */ -export const closePit = ({ - client, - pitId, -}: ClosePitParams): TaskEither.TaskEither => () => { - return client - .closePointInTime({ - body: { id: pitId }, - }) - .then((response) => { - if (!response.body.succeeded) { - throw new Error(`Failed to close PointInTime with id: ${pitId}`); - } - return Either.right({}); - }) - .catch(catchRetryableEsClientErrors); -}; +export type { BulkOverwriteTransformedDocumentsParams } from './bulk_overwrite_transformed_documents'; +export { bulkOverwriteTransformedDocuments } from './bulk_overwrite_transformed_documents'; -/** @internal */ -export interface TransformDocsParams { - transformRawDocs: TransformRawDocs; - outdatedDocuments: SavedObjectsRawDoc[]; -} -/* - * Transform outdated docs - * */ -export const transformDocs = ({ - transformRawDocs, - outdatedDocuments, -}: TransformDocsParams): TaskEither.TaskEither< - DocumentsTransformFailed, - DocumentsTransformSuccess -> => transformRawDocs(outdatedDocuments); +export { pickupUpdatedMappings, waitForTask, waitForIndexStatusYellow }; +export type { AliasNotFound, RemoveIndexNotAConcreteIndex }; -/** @internal */ -export interface ReindexResponse { - taskId: string; -} - -/** @internal */ -export interface RefreshIndexParams { - client: ElasticsearchClient; - targetIndex: string; -} -/** - * Wait for Elasticsearch to reindex all the changes. - */ -export const refreshIndex = ({ - client, - targetIndex, -}: RefreshIndexParams): TaskEither.TaskEither< - RetryableEsClientError, - { refreshed: boolean } -> => () => { - return client.indices - .refresh({ - index: targetIndex, - }) - .then(() => { - return Either.right({ refreshed: true }); - }) - .catch(catchRetryableEsClientErrors); -}; -/** @internal */ -export interface ReindexParams { - client: ElasticsearchClient; - sourceIndex: string; - targetIndex: string; - reindexScript: Option.Option; - requireAlias: boolean; - /* When reindexing we use a source query to exclude saved objects types which - * are no longer used. These saved objects will still be kept in the outdated - * index for backup purposes, but won't be available in the upgraded index. - */ - unusedTypesQuery: estypes.QueryContainer; +export interface IndexNotFound { + type: 'index_not_found_exception'; + index: string; } -/** - * Reindex documents from the `sourceIndex` into the `targetIndex`. Returns a - * task ID which can be tracked for progress. - * - * @remarks This action is idempotent allowing several Kibana instances to run - * this in parallel. By using `op_type: 'create', conflicts: 'proceed'` there - * will be only one write per reindexed document. - */ -export const reindex = ({ - client, - sourceIndex, - targetIndex, - reindexScript, - requireAlias, - unusedTypesQuery, -}: ReindexParams): TaskEither.TaskEither => () => { - return client - .reindex({ - // Require targetIndex to be an alias. Prevents a new index from being - // created if targetIndex doesn't exist. - require_alias: requireAlias, - body: { - // Ignore version conflicts from existing documents - conflicts: 'proceed', - source: { - index: sourceIndex, - // Set reindex batch size - size: BATCH_SIZE, - // Exclude saved object types - query: unusedTypesQuery, - }, - dest: { - index: targetIndex, - // Don't override existing documents, only create if missing - op_type: 'create', - }, - script: Option.fold( - () => undefined, - (script) => ({ - source: script, - lang: 'painless', - }) - )(reindexScript), - }, - // force a refresh so that we can query the target index - refresh: true, - // Create a task and return task id instead of blocking until complete - wait_for_completion: false, - }) - .then(({ body: { task: taskId } }) => { - return Either.right({ taskId: String(taskId) }); - }) - .catch(catchRetryableEsClientErrors); -}; - -interface WaitForReindexTaskFailure { +export interface WaitForReindexTaskFailure { readonly cause: { type: string; reason: string }; } - -/** @internal */ export interface TargetIndexHadWriteBlock { type: 'target_index_had_write_block'; } -/** @internal */ -export interface IncompatibleMappingException { - type: 'incompatible_mapping_exception'; -} - -export const waitForReindexTask = flow( - waitForTask, - TaskEither.chain( - ( - res - ): TaskEither.TaskEither< - | IndexNotFound - | TargetIndexHadWriteBlock - | IncompatibleMappingException - | RetryableEsClientError - | WaitForTaskCompletionTimeout, - 'reindex_succeeded' - > => { - const failureIsAWriteBlock = ({ cause: { type, reason } }: WaitForReindexTaskFailure) => - type === 'cluster_block_exception' && - reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/index write \(api\)\]/); - - const failureIsIncompatibleMappingException = ({ - cause: { type, reason }, - }: WaitForReindexTaskFailure) => - type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; - - if (Option.isSome(res.error)) { - if (res.error.value.type === 'index_not_found_exception') { - return TaskEither.left({ - type: 'index_not_found_exception' as const, - index: res.error.value.index, - }); - } else { - throw new Error('Reindex failed with the following error:\n' + JSON.stringify(res.error)); - } - } else if (Option.isSome(res.failures)) { - if (res.failures.value.every(failureIsAWriteBlock)) { - return TaskEither.left({ type: 'target_index_had_write_block' as const }); - } else if (res.failures.value.every(failureIsIncompatibleMappingException)) { - return TaskEither.left({ type: 'incompatible_mapping_exception' as const }); - } else { - throw new Error( - 'Reindex failed with the following failures:\n' + JSON.stringify(res.failures.value) - ); - } - } else { - return TaskEither.right('reindex_succeeded' as const); - } - } - ) -); - -/** @internal */ -export interface VerifyReindexParams { - client: ElasticsearchClient; - sourceIndex: string; - targetIndex: string; -} - -export const verifyReindex = ({ - client, - sourceIndex, - targetIndex, -}: VerifyReindexParams): TaskEither.TaskEither< - RetryableEsClientError | { type: 'verify_reindex_failed' }, - 'verify_reindex_succeeded' -> => () => { - const count = (index: string) => - client - .count<{ count: number }>({ - index, - // Return an error when targeting missing or closed indices - allow_no_indices: false, - }) - .then((res) => { - return res.body.count; - }); - - return Promise.all([count(sourceIndex), count(targetIndex)]) - .then(([sourceCount, targetCount]) => { - if (targetCount >= sourceCount) { - return Either.right('verify_reindex_succeeded' as const); - } else { - return Either.left({ type: 'verify_reindex_failed' as const }); - } - }) - .catch(catchRetryableEsClientErrors); -}; - -export const waitForPickupUpdatedMappingsTask = flow( - waitForTask, - TaskEither.chain( - ( - res - ): TaskEither.TaskEither< - RetryableEsClientError | WaitForTaskCompletionTimeout, - 'pickup_updated_mappings_succeeded' - > => { - // We don't catch or type failures/errors because they should never - // occur in our migration algorithm and we don't have any business logic - // for dealing with it. If something happens we'll just crash and try - // again. - if (Option.isSome(res.failures)) { - throw new Error( - 'pickupUpdatedMappings task failed with the following failures:\n' + - JSON.stringify(res.failures.value) - ); - } else if (Option.isSome(res.error)) { - throw new Error( - 'pickupUpdatedMappings task failed with the following error:\n' + - JSON.stringify(res.error.value) - ); - } else { - return TaskEither.right('pickup_updated_mappings_succeeded' as const); - } - } - ) -); -export interface AliasNotFound { - type: 'alias_not_found_exception'; -} - -/** @internal */ -export interface RemoveIndexNotAConcreteIndex { - type: 'remove_index_not_a_concrete_index'; -} - -/** @internal */ -export type AliasAction = - | { remove_index: { index: string } } - | { remove: { index: string; alias: string; must_exist: boolean } } - | { add: { index: string; alias: string } }; - -/** @internal */ -export interface UpdateAliasesParams { - client: ElasticsearchClient; - aliasActions: AliasAction[]; -} -/** - * Calls the Update index alias API `_alias` with the provided alias actions. - */ -export const updateAliases = ({ - client, - aliasActions, -}: UpdateAliasesParams): TaskEither.TaskEither< - IndexNotFound | AliasNotFound | RemoveIndexNotAConcreteIndex | RetryableEsClientError, - 'update_aliases_succeeded' -> => () => { - return client.indices - .updateAliases( - { - body: { - actions: aliasActions, - }, - }, - { maxRetries: 0 } - ) - .then(() => { - // Ignore `acknowledged: false`. When the coordinating node accepts - // the new cluster state update but not all nodes have applied the - // update within the timeout `acknowledged` will be false. However, - // retrying this update will always immediately result in `acknowledged: - // true` even if there are still nodes which are falling behind with - // cluster state updates. - // The only impact for using `updateAliases` to mark the version index - // as ready is that it could take longer for other Kibana instances to - // see that the version index is ready so they are more likely to - // perform unecessary duplicate work. - return Either.right('update_aliases_succeeded' as const); - }) - .catch((err: EsErrors.ElasticsearchClientError) => { - if (err instanceof EsErrors.ResponseError) { - if (err?.body?.error?.type === 'index_not_found_exception') { - return Either.left({ - type: 'index_not_found_exception' as const, - index: err.body.error.index, - }); - } else if ( - err?.body?.error?.type === 'illegal_argument_exception' && - err?.body?.error?.reason?.match( - /The provided expression \[.+\] matches an alias, specify the corresponding concrete indices instead./ - ) - ) { - return Either.left({ type: 'remove_index_not_a_concrete_index' as const }); - } else if ( - err?.body?.error?.type === 'aliases_not_found_exception' || - (err?.body?.error?.type === 'resource_not_found_exception' && - err?.body?.error?.reason?.match(/required alias \[.+\] does not exist/)) - ) { - return Either.left({ - type: 'alias_not_found_exception' as const, - }); - } - } - throw err; - }) - .catch(catchRetryableEsClientErrors); -}; - /** @internal */ export interface AcknowledgeResponse { acknowledged: boolean; shardsAcknowledged: boolean; } - -function aliasArrayToRecord(aliases: string[]): Record { - const result: Record = {}; - for (const alias of aliases) { - result[alias] = {}; - } - return result; -} - -/** @internal */ -export interface CreateIndexParams { - client: ElasticsearchClient; - indexName: string; - mappings: IndexMapping; - aliases?: string[]; -} -/** - * Creates an index with the given mappings - * - * @remarks - * This method adds some additional logic to the ES create index API: - * - it is idempotent, if it gets called multiple times subsequent calls will - * wait for the first create operation to complete (up to 60s) - * - the first call will wait up to 120s for the cluster state and all shards - * to be updated. - */ -export const createIndex = ({ - client, - indexName, - mappings, - aliases = [], -}: CreateIndexParams): TaskEither.TaskEither => { - const createIndexTask: TaskEither.TaskEither< - RetryableEsClientError, - AcknowledgeResponse - > = () => { - const aliasesObject = aliasArrayToRecord(aliases); - - return client.indices - .create( - { - index: indexName, - // wait until all shards are available before creating the index - // (since number_of_shards=1 this does not have any effect atm) - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - // Wait up to 60s for the cluster state to update and all shards to be - // started - timeout: DEFAULT_TIMEOUT, - body: { - mappings, - aliases: aliasesObject, - settings: { - index: { - // ES rule of thumb: shards should be several GB to 10's of GB, so - // Kibana is unlikely to cross that limit. - number_of_shards: 1, - auto_expand_replicas: INDEX_AUTO_EXPAND_REPLICAS, - // Set an explicit refresh interval so that we don't inherit the - // value from incorrectly configured index templates (not required - // after we adopt system indices) - refresh_interval: '1s', - // Bump priority so that recovery happens before newer indices - priority: 10, - }, - }, - }, - }, - { maxRetries: 0 /** handle retry ourselves for now */ } - ) - .then((res) => { - /** - * - acknowledged=false, we timed out before the cluster state was - * updated on all nodes with the newly created index, but it - * probably will be created sometime soon. - * - shards_acknowledged=false, we timed out before all shards were - * started - * - acknowledged=true, shards_acknowledged=true, index creation complete - */ - return Either.right({ - acknowledged: res.body.acknowledged, - shardsAcknowledged: res.body.shards_acknowledged, - }); - }) - .catch((error) => { - if (error?.body?.error?.type === 'resource_already_exists_exception') { - /** - * If the target index already exists it means a previous create - * operation had already been started. However, we can't be sure - * that all shards were started so return shardsAcknowledged: false - */ - return Either.right({ - acknowledged: true, - shardsAcknowledged: false, - }); - } else { - throw error; - } - }) - .catch(catchRetryableEsClientErrors); - }; - - return pipe( - createIndexTask, - TaskEither.chain((res) => { - if (res.acknowledged && res.shardsAcknowledged) { - // If the cluster state was updated and all shards ackd we're done - return TaskEither.right('create_index_succeeded'); - } else { - // Otherwise, wait until the target index has a 'yellow' status. - return pipe( - waitForIndexStatusYellow({ client, index: indexName, timeout: DEFAULT_TIMEOUT }), - TaskEither.map(() => { - /** When the index status is 'yellow' we know that all shards were started */ - return 'create_index_succeeded'; - }) - ); - } - }) - ); -}; - -/** @internal */ -export interface UpdateAndPickupMappingsResponse { - taskId: string; -} - -/** @internal */ -export interface UpdateAndPickupMappingsParams { - client: ElasticsearchClient; - index: string; - mappings: IndexMapping; -} -/** - * Updates an index's mappings and runs an pickupUpdatedMappings task so that the mapping - * changes are "picked up". Returns a taskId to track progress. - */ -export const updateAndPickupMappings = ({ - client, - index, - mappings, -}: UpdateAndPickupMappingsParams): TaskEither.TaskEither< - RetryableEsClientError, - UpdateAndPickupMappingsResponse -> => { - const putMappingTask: TaskEither.TaskEither< - RetryableEsClientError, - 'update_mappings_succeeded' - > = () => { - return client.indices - .putMapping({ - index, - timeout: DEFAULT_TIMEOUT, - body: mappings, - }) - .then((res) => { - // Ignore `acknowledged: false`. When the coordinating node accepts - // the new cluster state update but not all nodes have applied the - // update within the timeout `acknowledged` will be false. However, - // retrying this update will always immediately result in `acknowledged: - // true` even if there are still nodes which are falling behind with - // cluster state updates. - // For updateAndPickupMappings this means that there is the potential - // that some existing document's fields won't be picked up if the node - // on which the Kibana shard is running has fallen behind with cluster - // state updates and the mapping update wasn't applied before we run - // `pickupUpdatedMappings`. ES tries to limit this risk by blocking - // index operations (including update_by_query used by - // updateAndPickupMappings) if there are pending mappings changes. But - // not all mapping changes will prevent this. - return Either.right('update_mappings_succeeded' as const); - }) - .catch(catchRetryableEsClientErrors); - }; - - return pipe( - putMappingTask, - TaskEither.chain((res) => { - return pickupUpdatedMappings(client, index); - }) - ); -}; - -/** @internal */ -export interface SearchResponse { - outdatedDocuments: SavedObjectsRawDoc[]; -} - -interface SearchForOutdatedDocumentsOptions { - batchSize: number; - targetIndex: string; - outdatedDocumentsQuery?: estypes.QueryContainer; +// Map of left response 'type' string -> response interface +export interface ActionErrorTypeMap { + wait_for_task_completion_timeout: WaitForTaskCompletionTimeout; + retryable_es_client_error: RetryableEsClientError; + index_not_found_exception: IndexNotFound; + target_index_had_write_block: TargetIndexHadWriteBlock; + incompatible_mapping_exception: IncompatibleMappingException; + alias_not_found_exception: AliasNotFound; + remove_index_not_a_concrete_index: RemoveIndexNotAConcreteIndex; + documents_transform_failed: DocumentsTransformFailed; } /** - * Search for outdated saved object documents with the provided query. Will - * return one batch of documents. Searching should be repeated until no more - * outdated documents can be found. - * - * Used for testing only + * Type guard for narrowing the type of a left */ -export const searchForOutdatedDocuments = ( - client: ElasticsearchClient, - options: SearchForOutdatedDocumentsOptions -): TaskEither.TaskEither => () => { - return client - .search({ - index: options.targetIndex, - // Return the _seq_no and _primary_term so we can use optimistic - // concurrency control for updates - seq_no_primary_term: true, - size: options.batchSize, - body: { - query: options.outdatedDocumentsQuery, - // Optimize search performance by sorting by the "natural" index order - sort: ['_doc'], - }, - // Return an error when targeting missing or closed indices - allow_no_indices: false, - // Don't return partial results if timeouts or shard failures are - // encountered. This is important because 0 search hits is interpreted as - // there being no more outdated documents left that require - // transformation. Although the default is `false`, we set this - // explicitly to avoid users overriding the - // search.default_allow_partial_results cluster setting to true. - allow_partial_search_results: false, - // Improve performance by not calculating the total number of hits - // matching the query. - track_total_hits: false, - // Reduce the response payload size by only returning the data we care about - filter_path: [ - 'hits.hits._id', - 'hits.hits._source', - 'hits.hits._seq_no', - 'hits.hits._primary_term', - ], - }) - .then((res) => - Either.right({ outdatedDocuments: (res.body.hits?.hits as SavedObjectsRawDoc[]) ?? [] }) - ) - .catch(catchRetryableEsClientErrors); -}; - -/** @internal */ -export interface BulkOverwriteTransformedDocumentsParams { - client: ElasticsearchClient; - index: string; - transformedDocs: SavedObjectsRawDoc[]; - refresh?: estypes.Refresh; +export function isLeftTypeof( + res: any, + typeString: T +): res is ActionErrorTypeMap[T] { + return res.type === typeString; } -/** - * Write the up-to-date transformed documents to the index, overwriting any - * documents that are still on their outdated version. - */ -export const bulkOverwriteTransformedDocuments = ({ - client, - index, - transformedDocs, - refresh = false, -}: BulkOverwriteTransformedDocumentsParams): TaskEither.TaskEither< - RetryableEsClientError, - 'bulk_index_succeeded' -> => () => { - return client - .bulk({ - // Because we only add aliases in the MARK_VERSION_INDEX_READY step we - // can't bulkIndex to an alias with require_alias=true. This means if - // users tamper during this operation (delete indices or restore a - // snapshot), we could end up auto-creating an index without the correct - // mappings. Such tampering could lead to many other problems and is - // probably unlikely so for now we'll accept this risk and wait till - // system indices puts in place a hard control. - require_alias: false, - wait_for_active_shards: WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, - refresh, - filter_path: ['items.*.error'], - body: transformedDocs.flatMap((doc) => { - return [ - { - index: { - _index: index, - _id: doc._id, - // overwrite existing documents - op_type: 'index', - // use optimistic concurrency control to ensure that outdated - // documents are only overwritten once with the latest version - if_seq_no: doc._seq_no, - if_primary_term: doc._primary_term, - }, - }, - doc._source, - ]; - }), - }) - .then((res) => { - // Filter out version_conflict_engine_exception since these just mean - // that another instance already updated these documents - const errors = (res.body.items ?? []).filter( - (item) => item.index?.error?.type !== 'version_conflict_engine_exception' - ); - if (errors.length === 0) { - return Either.right('bulk_index_succeeded' as const); - } else { - throw new Error(JSON.stringify(errors)); - } - }) - .catch(catchRetryableEsClientErrors); -}; diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts similarity index 99% rename from src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts rename to src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts index 67a2685caf3d61..b508a6198bfb34 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrationsv2/actions/integration_tests/actions.test.ts @@ -6,11 +6,11 @@ * Side Public License, v 1. */ -import { ElasticsearchClient } from '../../../'; -import { InternalCoreStart } from '../../../internal_types'; -import * as kbnTestServer from '../../../../test_helpers/kbn_server'; -import { Root } from '../../../root'; -import { SavedObjectsRawDoc } from '../../serialization'; +import { ElasticsearchClient } from '../../../../'; +import { InternalCoreStart } from '../../../../internal_types'; +import * as kbnTestServer from '../../../../../test_helpers/kbn_server'; +import { Root } from '../../../../root'; +import { SavedObjectsRawDoc } from '../../../serialization'; import { bulkOverwriteTransformedDocuments, cloneIndex, @@ -37,11 +37,11 @@ import { removeWriteBlock, transformDocs, waitForIndexStatusYellow, -} from '../actions'; +} from '../../actions'; import * as Either from 'fp-ts/lib/Either'; import * as Option from 'fp-ts/lib/Option'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; -import { DocumentsTransformFailed, DocumentsTransformSuccess } from '../../migrations/core'; +import { DocumentsTransformFailed, DocumentsTransformSuccess } from '../../../migrations/core'; import { TaskEither } from 'fp-ts/lib/TaskEither'; const { startES } = kbnTestServer.createTestServers({ diff --git a/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.ts new file mode 100644 index 00000000000000..c8fc29d06f42f7 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/open_pit.test.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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { openPit } from './open_pit'; + +describe('openPit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = openPit({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts new file mode 100644 index 00000000000000..e740dc00ac27ea --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/open_pit.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +/** @internal */ +export interface OpenPitResponse { + pitId: string; +} + +/** @internal */ +export interface OpenPitParams { + client: ElasticsearchClient; + index: string; +} +// how long ES should keep PIT alive +export const pitKeepAlive = '10m'; +/* + * Creates a lightweight view of data when the request has been initiated. + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html + * */ +export const openPit = ({ + client, + index, +}: OpenPitParams): TaskEither.TaskEither => () => { + return client + .openPointInTime({ + index, + keep_alive: pitKeepAlive, + }) + .then((response) => Either.right({ pitId: response.body.id })) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts new file mode 100644 index 00000000000000..e319d4149dd1af --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { errors as EsErrors } from '@elastic/elasticsearch'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; + +describe('pickupUpdatedMappings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = pickupUpdatedMappings(client, 'my_index'); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts new file mode 100644 index 00000000000000..8cc609e5277bcc --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/pickup_updated_mappings.ts @@ -0,0 +1,57 @@ +/* + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { BATCH_SIZE } from './constants'; +export interface UpdateByQueryResponse { + taskId: string; +} + +/** + * Pickup updated mappings by performing an update by query operation on all + * documents in the index. Returns a task ID which can be + * tracked for progress. + * + * @remarks When mappings are updated to add a field which previously wasn't + * mapped Elasticsearch won't automatically add existing documents to it's + * internal search indices. So search results on this field won't return any + * existing documents. By running an update by query we essentially refresh + * these the internal search indices for all existing documents. + * This action uses `conflicts: 'proceed'` allowing several Kibana instances + * to run this in parallel. + */ +export const pickupUpdatedMappings = ( + client: ElasticsearchClient, + index: string +): TaskEither.TaskEither => () => { + return client + .updateByQuery({ + // Ignore version conflicts that can occur from parallel update by query operations + conflicts: 'proceed', + // Return an error when targeting missing or closed indices + allow_no_indices: false, + index, + // How many documents to update per batch + scroll_size: BATCH_SIZE, + // force a refresh so that we can query the updated index immediately + // after the operation completes + refresh: true, + // Create a task and return task id instead of blocking until complete + wait_for_completion: false, + }) + .then(({ body: { task: taskId } }) => { + return Either.right({ taskId: String(taskId!) }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts new file mode 100644 index 00000000000000..0d8d76b45a57b3 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { readWithPit } from './read_with_pit'; + +describe('readWithPit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = readWithPit({ + client, + pitId: 'pitId', + query: { match_all: {} }, + batchSize: 10_000, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts new file mode 100644 index 00000000000000..16f1df05f26b3e --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/read_with_pit.ts @@ -0,0 +1,92 @@ +/* + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { pitKeepAlive } from './open_pit'; + +/** @internal */ +export interface ReadWithPit { + outdatedDocuments: SavedObjectsRawDoc[]; + readonly lastHitSortValue: number[] | undefined; + readonly totalHits: number | undefined; +} + +/** @internal */ +export interface ReadWithPitParams { + client: ElasticsearchClient; + pitId: string; + query: estypes.QueryContainer; + batchSize: number; + searchAfter?: number[]; + seqNoPrimaryTerm?: boolean; +} + +/* + * Requests documents from the index using PIT mechanism. + * */ +export const readWithPit = ({ + client, + pitId, + query, + batchSize, + searchAfter, + seqNoPrimaryTerm, +}: ReadWithPitParams): TaskEither.TaskEither => () => { + return client + .search({ + seq_no_primary_term: seqNoPrimaryTerm, + body: { + // Sort fields are required to use searchAfter + sort: { + // the most efficient option as order is not important for the migration + _shard_doc: { order: 'asc' }, + }, + pit: { id: pitId, keep_alive: pitKeepAlive }, + size: batchSize, + search_after: searchAfter, + /** + * We want to know how many documents we need to process so we can log the progress. + * But we also want to increase the performance of these requests, + * so we ask ES to report the total count only on the first request (when searchAfter does not exist) + */ + track_total_hits: typeof searchAfter === 'undefined', + query, + }, + }) + .then((response) => { + const totalHits = + typeof response.body.hits.total === 'number' + ? response.body.hits.total // This format is to be removed in 8.0 + : response.body.hits.total?.value; + const hits = response.body.hits.hits; + + if (hits.length > 0) { + return Either.right({ + // @ts-expect-error @elastic/elasticsearch _source is optional + outdatedDocuments: hits as SavedObjectsRawDoc[], + lastHitSortValue: hits[hits.length - 1].sort as number[], + totalHits, + }); + } + + return Either.right({ + outdatedDocuments: [], + lastHitSortValue: undefined, + totalHits, + }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts new file mode 100644 index 00000000000000..0ebdb2b2b18519 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { refreshIndex } from './refresh_index'; + +describe('refreshIndex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = refreshIndex({ client, targetIndex: 'target_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.ts new file mode 100644 index 00000000000000..e7bcbfb7d2d532 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/refresh_index.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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; + +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface RefreshIndexParams { + client: ElasticsearchClient; + targetIndex: string; +} +/** + * Wait for Elasticsearch to reindex all the changes. + */ +export const refreshIndex = ({ + client, + targetIndex, +}: RefreshIndexParams): TaskEither.TaskEither< + RetryableEsClientError, + { refreshed: boolean } +> => () => { + return client.indices + .refresh({ + index: targetIndex, + }) + .then(() => { + return Either.right({ refreshed: true }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts b/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts new file mode 100644 index 00000000000000..f53368bd9321b6 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/reindex.test.ts @@ -0,0 +1,48 @@ +/* + * 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 * as Option from 'fp-ts/lib/Option'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { reindex } from './reindex'; + +describe('reindex', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = reindex({ + client, + sourceIndex: 'my_source_index', + targetIndex: 'my_target_index', + reindexScript: Option.none, + requireAlias: false, + unusedTypesQuery: {}, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/reindex.ts b/src/core/server/saved_objects/migrationsv2/actions/reindex.ts new file mode 100644 index 00000000000000..ca8d3b594703c3 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/reindex.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { BATCH_SIZE } from './constants'; + +/** @internal */ +export interface ReindexResponse { + taskId: string; +} +/** @internal */ +export interface ReindexParams { + client: ElasticsearchClient; + sourceIndex: string; + targetIndex: string; + reindexScript: Option.Option; + requireAlias: boolean; + /* When reindexing we use a source query to exclude saved objects types which + * are no longer used. These saved objects will still be kept in the outdated + * index for backup purposes, but won't be available in the upgraded index. + */ + unusedTypesQuery: estypes.QueryContainer; +} +/** + * Reindex documents from the `sourceIndex` into the `targetIndex`. Returns a + * task ID which can be tracked for progress. + * + * @remarks This action is idempotent allowing several Kibana instances to run + * this in parallel. By using `op_type: 'create', conflicts: 'proceed'` there + * will be only one write per reindexed document. + */ +export const reindex = ({ + client, + sourceIndex, + targetIndex, + reindexScript, + requireAlias, + unusedTypesQuery, +}: ReindexParams): TaskEither.TaskEither => () => { + return client + .reindex({ + // Require targetIndex to be an alias. Prevents a new index from being + // created if targetIndex doesn't exist. + require_alias: requireAlias, + body: { + // Ignore version conflicts from existing documents + conflicts: 'proceed', + source: { + index: sourceIndex, + // Set reindex batch size + size: BATCH_SIZE, + // Exclude saved object types + query: unusedTypesQuery, + }, + dest: { + index: targetIndex, + // Don't override existing documents, only create if missing + op_type: 'create', + }, + script: Option.fold( + () => undefined, + (script) => ({ + source: script, + lang: 'painless', + }) + )(reindexScript), + }, + // force a refresh so that we can query the target index + refresh: true, + // Create a task and return task id instead of blocking until complete + wait_for_completion: false, + }) + .then(({ body: { task: taskId } }) => { + return Either.right({ taskId: String(taskId) }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts new file mode 100644 index 00000000000000..497211cb693ab1 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { removeWriteBlock } from './remove_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('removeWriteBlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = removeWriteBlock({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = removeWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts new file mode 100644 index 00000000000000..c55e4a235fbf10 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/remove_write_block.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface RemoveWriteBlockParams { + client: ElasticsearchClient; + index: string; +} +/** + * Removes a write block from an index + */ +export const removeWriteBlock = ({ + client, + index, +}: RemoveWriteBlockParams): TaskEither.TaskEither< + RetryableEsClientError, + 'remove_write_block_succeeded' +> => () => { + return client.indices + .putSettings<{ + acknowledged: boolean; + shards_acknowledged: boolean; + }>( + { + index, + // Don't change any existing settings + preserve_existing: true, + body: { + index: { + blocks: { + write: false, + }, + }, + }, + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + .then((res) => { + return res.body.acknowledged === true + ? Either.right('remove_write_block_succeeded' as const) + : Either.left({ + type: 'retryable_es_client_error' as const, + message: 'remove_write_block_failed', + }); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts new file mode 100644 index 00000000000000..ab133e9a564be7 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { searchForOutdatedDocuments } from './search_for_outdated_documents'; + +describe('searchForOutdatedDocuments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = searchForOutdatedDocuments(client, { + batchSize: 1000, + targetIndex: 'new_index', + outdatedDocumentsQuery: {}, + }); + + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + + it('configures request according to given parameters', async () => { + const esClient = elasticsearchClientMock.createInternalClient(); + const query = {}; + const targetIndex = 'new_index'; + const batchSize = 1000; + const task = searchForOutdatedDocuments(esClient, { + batchSize, + targetIndex, + outdatedDocumentsQuery: query, + }); + + await task(); + + expect(esClient.search).toHaveBeenCalledTimes(1); + expect(esClient.search).toHaveBeenCalledWith( + expect.objectContaining({ + index: targetIndex, + size: batchSize, + body: expect.objectContaining({ query }), + }) + ); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts new file mode 100644 index 00000000000000..7406cd35b1593e --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/search_for_outdated_documents.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface SearchResponse { + outdatedDocuments: SavedObjectsRawDoc[]; +} + +export interface SearchForOutdatedDocumentsOptions { + batchSize: number; + targetIndex: string; + outdatedDocumentsQuery?: estypes.QueryContainer; +} + +/** + * Search for outdated saved object documents with the provided query. Will + * return one batch of documents. Searching should be repeated until no more + * outdated documents can be found. + * + * Used for testing only + */ +export const searchForOutdatedDocuments = ( + client: ElasticsearchClient, + options: SearchForOutdatedDocumentsOptions +): TaskEither.TaskEither => () => { + return client + .search({ + index: options.targetIndex, + // Return the _seq_no and _primary_term so we can use optimistic + // concurrency control for updates + seq_no_primary_term: true, + size: options.batchSize, + body: { + query: options.outdatedDocumentsQuery, + // Optimize search performance by sorting by the "natural" index order + sort: ['_doc'], + }, + // Return an error when targeting missing or closed indices + allow_no_indices: false, + // Don't return partial results if timeouts or shard failures are + // encountered. This is important because 0 search hits is interpreted as + // there being no more outdated documents left that require + // transformation. Although the default is `false`, we set this + // explicitly to avoid users overriding the + // search.default_allow_partial_results cluster setting to true. + allow_partial_search_results: false, + // Improve performance by not calculating the total number of hits + // matching the query. + track_total_hits: false, + // Reduce the response payload size by only returning the data we care about + filter_path: [ + 'hits.hits._id', + 'hits.hits._source', + 'hits.hits._seq_no', + 'hits.hits._primary_term', + ], + }) + .then((res) => + Either.right({ outdatedDocuments: (res.body.hits?.hits as SavedObjectsRawDoc[]) ?? [] }) + ) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts new file mode 100644 index 00000000000000..cf7b3091f38ffc --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.test.ts @@ -0,0 +1,52 @@ +/* + * 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 { setWriteBlock } from './set_write_block'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('setWriteBlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = setWriteBlock({ client, index: 'my_index' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts new file mode 100644 index 00000000000000..5aed316306cf91 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/set_write_block.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ElasticsearchClientError } from '@elastic/elasticsearch/lib/errors'; +import * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import type { IndexNotFound } from './'; + +/** @internal */ +export interface SetWriteBlockParams { + client: ElasticsearchClient; + index: string; +} +/** + * Sets a write block in place for the given index. If the response includes + * `acknowledged: true` all in-progress writes have drained and no further + * writes to this index will be possible. + * + * The first time the write block is added to an index the response will + * include `shards_acknowledged: true` but once the block is in place, + * subsequent calls return `shards_acknowledged: false` + */ +export const setWriteBlock = ({ + client, + index, +}: SetWriteBlockParams): TaskEither.TaskEither< + IndexNotFound | RetryableEsClientError, + 'set_write_block_succeeded' +> => () => { + return ( + client.indices + .addBlock<{ + acknowledged: boolean; + shards_acknowledged: boolean; + }>( + { + index, + block: 'write', + }, + { maxRetries: 0 /** handle retry ourselves for now */ } + ) + // not typed yet + .then((res: any) => { + return res.body.acknowledged === true + ? Either.right('set_write_block_succeeded' as const) + : Either.left({ + type: 'retryable_es_client_error' as const, + message: 'set_write_block_failed', + }); + }) + .catch((e: ElasticsearchClientError) => { + if (e instanceof EsErrors.ResponseError) { + if (e.body?.error?.type === 'index_not_found_exception') { + return Either.left({ type: 'index_not_found_exception' as const, index }); + } + } + throw e; + }) + .catch(catchRetryableEsClientErrors) + ); +}; +// diff --git a/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts b/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts new file mode 100644 index 00000000000000..4c712afcff3a45 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/transform_docs.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as TaskEither from 'fp-ts/lib/TaskEither'; +import type { TransformRawDocs } from '../types'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { + DocumentsTransformFailed, + DocumentsTransformSuccess, +} from '../../migrations/core/migrate_raw_docs'; + +/** @internal */ +export interface TransformDocsParams { + transformRawDocs: TransformRawDocs; + outdatedDocuments: SavedObjectsRawDoc[]; +} +/* + * Transform outdated docs + * */ +export const transformDocs = ({ + transformRawDocs, + outdatedDocuments, +}: TransformDocsParams): TaskEither.TaskEither< + DocumentsTransformFailed, + DocumentsTransformSuccess +> => transformRawDocs(outdatedDocuments); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts new file mode 100644 index 00000000000000..e2ea07d40281bf --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { updateAliases } from './update_aliases'; +import { setWriteBlock } from './set_write_block'; + +describe('updateAliases', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = updateAliases({ client, aliasActions: [] }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts new file mode 100644 index 00000000000000..ffb8002f092123 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_aliases.ts @@ -0,0 +1,98 @@ +/* + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { IndexNotFound } from './index'; + +export interface AliasNotFound { + type: 'alias_not_found_exception'; +} + +/** @internal */ +export interface RemoveIndexNotAConcreteIndex { + type: 'remove_index_not_a_concrete_index'; +} + +/** @internal */ +export type AliasAction = + | { remove_index: { index: string } } + | { remove: { index: string; alias: string; must_exist: boolean } } + | { add: { index: string; alias: string } }; + +/** @internal */ +export interface UpdateAliasesParams { + client: ElasticsearchClient; + aliasActions: AliasAction[]; +} +/** + * Calls the Update index alias API `_alias` with the provided alias actions. + */ +export const updateAliases = ({ + client, + aliasActions, +}: UpdateAliasesParams): TaskEither.TaskEither< + IndexNotFound | AliasNotFound | RemoveIndexNotAConcreteIndex | RetryableEsClientError, + 'update_aliases_succeeded' +> => () => { + return client.indices + .updateAliases( + { + body: { + actions: aliasActions, + }, + }, + { maxRetries: 0 } + ) + .then(() => { + // Ignore `acknowledged: false`. When the coordinating node accepts + // the new cluster state update but not all nodes have applied the + // update within the timeout `acknowledged` will be false. However, + // retrying this update will always immediately result in `acknowledged: + // true` even if there are still nodes which are falling behind with + // cluster state updates. + // The only impact for using `updateAliases` to mark the version index + // as ready is that it could take longer for other Kibana instances to + // see that the version index is ready so they are more likely to + // perform unecessary duplicate work. + return Either.right('update_aliases_succeeded' as const); + }) + .catch((err: EsErrors.ElasticsearchClientError) => { + if (err instanceof EsErrors.ResponseError) { + if (err?.body?.error?.type === 'index_not_found_exception') { + return Either.left({ + type: 'index_not_found_exception' as const, + index: err.body.error.index, + }); + } else if ( + err?.body?.error?.type === 'illegal_argument_exception' && + err?.body?.error?.reason?.match( + /The provided expression \[.+\] matches an alias, specify the corresponding concrete indices instead./ + ) + ) { + return Either.left({ type: 'remove_index_not_a_concrete_index' as const }); + } else if ( + err?.body?.error?.type === 'aliases_not_found_exception' || + (err?.body?.error?.type === 'resource_not_found_exception' && + err?.body?.error?.reason?.match(/required alias \[.+\] does not exist/)) + ) { + return Either.left({ + type: 'alias_not_found_exception' as const, + }); + } + } + throw err; + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts new file mode 100644 index 00000000000000..3ecb990cd9e825 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { updateAndPickupMappings } from './update_and_pickup_mappings'; + +describe('updateAndPickupMappings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = updateAndPickupMappings({ + client, + index: 'new_index', + mappings: { properties: {} }, + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts new file mode 100644 index 00000000000000..8c742005a01ce1 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/update_and_pickup_mappings.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { IndexMapping } from '../../mappings'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { pickupUpdatedMappings } from './pickup_updated_mappings'; +import { DEFAULT_TIMEOUT } from './constants'; + +/** @internal */ +export interface UpdateAndPickupMappingsResponse { + taskId: string; +} + +/** @internal */ +export interface UpdateAndPickupMappingsParams { + client: ElasticsearchClient; + index: string; + mappings: IndexMapping; +} +/** + * Updates an index's mappings and runs an pickupUpdatedMappings task so that the mapping + * changes are "picked up". Returns a taskId to track progress. + */ +export const updateAndPickupMappings = ({ + client, + index, + mappings, +}: UpdateAndPickupMappingsParams): TaskEither.TaskEither< + RetryableEsClientError, + UpdateAndPickupMappingsResponse +> => { + const putMappingTask: TaskEither.TaskEither< + RetryableEsClientError, + 'update_mappings_succeeded' + > = () => { + return client.indices + .putMapping({ + index, + timeout: DEFAULT_TIMEOUT, + body: mappings, + }) + .then((res) => { + // Ignore `acknowledged: false`. When the coordinating node accepts + // the new cluster state update but not all nodes have applied the + // update within the timeout `acknowledged` will be false. However, + // retrying this update will always immediately result in `acknowledged: + // true` even if there are still nodes which are falling behind with + // cluster state updates. + // For updateAndPickupMappings this means that there is the potential + // that some existing document's fields won't be picked up if the node + // on which the Kibana shard is running has fallen behind with cluster + // state updates and the mapping update wasn't applied before we run + // `pickupUpdatedMappings`. ES tries to limit this risk by blocking + // index operations (including update_by_query used by + // updateAndPickupMappings) if there are pending mappings changes. But + // not all mapping changes will prevent this. + return Either.right('update_mappings_succeeded' as const); + }) + .catch(catchRetryableEsClientErrors); + }; + + return pipe( + putMappingTask, + TaskEither.chain((res) => { + return pickupUpdatedMappings(client, index); + }) + ); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts b/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts new file mode 100644 index 00000000000000..4db599d8fbadf4 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/verify_reindex.ts @@ -0,0 +1,52 @@ +/* + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; + +/** @internal */ +export interface VerifyReindexParams { + client: ElasticsearchClient; + sourceIndex: string; + targetIndex: string; +} + +export const verifyReindex = ({ + client, + sourceIndex, + targetIndex, +}: VerifyReindexParams): TaskEither.TaskEither< + RetryableEsClientError | { type: 'verify_reindex_failed' }, + 'verify_reindex_succeeded' +> => () => { + const count = (index: string) => + client + .count<{ count: number }>({ + index, + // Return an error when targeting missing or closed indices + allow_no_indices: false, + }) + .then((res) => { + return res.body.count; + }); + + return Promise.all([count(sourceIndex), count(targetIndex)]) + .then(([sourceCount, targetCount]) => { + if (targetCount >= sourceCount) { + return Either.right('verify_reindex_succeeded' as const); + } else { + return Either.left({ type: 'verify_reindex_failed' as const }); + } + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts new file mode 100644 index 00000000000000..8cea34b80ffad1 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { errors as EsErrors } from '@elastic/elasticsearch'; +import { waitForIndexStatusYellow } from './wait_for_index_status_yellow'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('waitForIndexStatusYellow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForIndexStatusYellow({ + client, + index: 'my_index', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts new file mode 100644 index 00000000000000..307c77ee5b89c7 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_index_status_yellow.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +import { DEFAULT_TIMEOUT } from './constants'; + +/** @internal */ +export interface WaitForIndexStatusYellowParams { + client: ElasticsearchClient; + index: string; + timeout?: string; +} +/** + * A yellow index status means the index's primary shard is allocated and the + * index is ready for searching/indexing documents, but ES wasn't able to + * allocate the replicas. When migrations proceed with a yellow index it means + * we don't have as much data-redundancy as we could have, but waiting for + * replicas would mean that v2 migrations fail where v1 migrations would have + * succeeded. It doesn't feel like it's Kibana's job to force users to keep + * their clusters green and even if it's green when we migrate it can turn + * yellow at any point in the future. So ultimately data-redundancy is up to + * users to maintain. + */ +export const waitForIndexStatusYellow = ({ + client, + index, + timeout = DEFAULT_TIMEOUT, +}: WaitForIndexStatusYellowParams): TaskEither.TaskEither => () => { + return client.cluster + .health({ index, wait_for_status: 'yellow', timeout }) + .then(() => { + return Either.right({}); + }) + .catch(catchRetryableEsClientErrors); +}; diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts new file mode 100644 index 00000000000000..f7c380be9427cb --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { waitForPickupUpdatedMappingsTask } from './wait_for_pickup_updated_mappings_task'; +import { setWriteBlock } from './set_write_block'; + +describe('waitForPickupUpdatedMappingsTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForPickupUpdatedMappingsTask({ + client, + taskId: 'my task id', + timeout: '60s', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts new file mode 100644 index 00000000000000..02f7c3455cec90 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_pickup_updated_mappings_task.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { flow } from 'fp-ts/lib/function'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; + +export const waitForPickupUpdatedMappingsTask = flow( + waitForTask, + TaskEither.chain( + ( + res + ): TaskEither.TaskEither< + RetryableEsClientError | WaitForTaskCompletionTimeout, + 'pickup_updated_mappings_succeeded' + > => { + // We don't catch or type failures/errors because they should never + // occur in our migration algorithm and we don't have any business logic + // for dealing with it. If something happens we'll just crash and try + // again. + if (Option.isSome(res.failures)) { + throw new Error( + 'pickupUpdatedMappings task failed with the following failures:\n' + + JSON.stringify(res.failures.value) + ); + } else if (Option.isSome(res.error)) { + throw new Error( + 'pickupUpdatedMappings task failed with the following error:\n' + + JSON.stringify(res.error.value) + ); + } else { + return TaskEither.right('pickup_updated_mappings_succeeded' as const); + } + } + ) +); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts new file mode 100644 index 00000000000000..f6a236aab5c85b --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +jest.mock('./catch_retryable_es_client_errors'); +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { waitForReindexTask } from './wait_for_reindex_task'; +import { setWriteBlock } from './set_write_block'; + +describe('waitForReindexTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + const nonRetryableError = new Error('crash'); + const clientWithNonRetryableError = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(nonRetryableError) + ); + + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForReindexTask({ client, taskId: 'my task id', timeout: '60s' }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + it('re-throws non retry-able errors', async () => { + const task = setWriteBlock({ + client: clientWithNonRetryableError, + index: 'my_index', + }); + await task(); + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(nonRetryableError); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts new file mode 100644 index 00000000000000..fcadb5e80298a7 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_reindex_task.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { flow } from 'fp-ts/lib/function'; +import { RetryableEsClientError } from './catch_retryable_es_client_errors'; +import type { IndexNotFound, WaitForReindexTaskFailure, TargetIndexHadWriteBlock } from './index'; +import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; + +export interface IncompatibleMappingException { + type: 'incompatible_mapping_exception'; +} +export const waitForReindexTask = flow( + waitForTask, + TaskEither.chain( + ( + res + ): TaskEither.TaskEither< + | IndexNotFound + | TargetIndexHadWriteBlock + | IncompatibleMappingException + | RetryableEsClientError + | WaitForTaskCompletionTimeout, + 'reindex_succeeded' + > => { + const failureIsAWriteBlock = ({ cause: { type, reason } }: WaitForReindexTaskFailure) => + type === 'cluster_block_exception' && + reason.match(/index \[.+] blocked by: \[FORBIDDEN\/8\/index write \(api\)\]/); + + const failureIsIncompatibleMappingException = ({ + cause: { type, reason }, + }: WaitForReindexTaskFailure) => + type === 'strict_dynamic_mapping_exception' || type === 'mapper_parsing_exception'; + + if (Option.isSome(res.error)) { + if (res.error.value.type === 'index_not_found_exception') { + return TaskEither.left({ + type: 'index_not_found_exception' as const, + index: res.error.value.index, + }); + } else { + throw new Error('Reindex failed with the following error:\n' + JSON.stringify(res.error)); + } + } else if (Option.isSome(res.failures)) { + if (res.failures.value.every(failureIsAWriteBlock)) { + return TaskEither.left({ type: 'target_index_had_write_block' as const }); + } else if (res.failures.value.every(failureIsIncompatibleMappingException)) { + return TaskEither.left({ type: 'incompatible_mapping_exception' as const }); + } else { + throw new Error( + 'Reindex failed with the following failures:\n' + JSON.stringify(res.failures.value) + ); + } + } else { + return TaskEither.right('reindex_succeeded' as const); + } + } + ) +); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts new file mode 100644 index 00000000000000..c7ca9bf36a2c62 --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { errors as EsErrors } from '@elastic/elasticsearch'; +import { waitForTask } from './wait_for_task'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; +jest.mock('./catch_retryable_es_client_errors'); + +describe('waitForTask', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Create a mock client that rejects all methods with a 503 status code + // response. + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); + + describe('waitForPickupUpdatedMappingsTask', () => { + it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const task = waitForTask({ + client, + taskId: 'my task id', + timeout: '60s', + }); + try { + await task(); + } catch (e) { + /** ignore */ + } + + expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); + }); + }); +}); diff --git a/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts new file mode 100644 index 00000000000000..4e3631797e34bc --- /dev/null +++ b/src/core/server/saved_objects/migrationsv2/actions/wait_for_task.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as Either from 'fp-ts/lib/Either'; +import * as TaskEither from 'fp-ts/lib/TaskEither'; +import * as Option from 'fp-ts/lib/Option'; +import { errors as EsErrors } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from '../../../elasticsearch'; +import { + catchRetryableEsClientErrors, + RetryableEsClientError, +} from './catch_retryable_es_client_errors'; +/** @internal */ +export interface WaitForTaskResponse { + error: Option.Option<{ type: string; reason: string; index: string }>; + completed: boolean; + failures: Option.Option; + description?: string; +} + +/** + * After waiting for the specificed timeout, the task has not yet completed. + * + * When querying the tasks API we use `wait_for_completion=true` to block the + * request until the task completes. If after the `timeout`, the task still has + * not completed we return this error. This does not mean that the task itelf + * has reached a timeout, Elasticsearch will continue to run the task. + */ +export interface WaitForTaskCompletionTimeout { + /** After waiting for the specificed timeout, the task has not yet completed. */ + readonly type: 'wait_for_task_completion_timeout'; + readonly message: string; + readonly error?: Error; +} + +const catchWaitForTaskCompletionTimeout = ( + e: EsErrors.ResponseError +): Either.Either => { + if ( + e.body?.error?.type === 'timeout_exception' || + e.body?.error?.type === 'receive_timeout_transport_exception' + ) { + return Either.left({ + type: 'wait_for_task_completion_timeout' as const, + message: `[${e.body.error.type}] ${e.body.error.reason}`, + error: e, + }); + } else { + throw e; + } +}; + +/** @internal */ +export interface WaitForTaskParams { + client: ElasticsearchClient; + taskId: string; + timeout: string; +} +/** + * Blocks for up to 60s or until a task completes. + * + * TODO: delete completed tasks + */ +export const waitForTask = ({ + client, + taskId, + timeout, +}: WaitForTaskParams): TaskEither.TaskEither< + RetryableEsClientError | WaitForTaskCompletionTimeout, + WaitForTaskResponse +> => () => { + return client.tasks + .get({ + task_id: taskId, + wait_for_completion: true, + timeout, + }) + .then((res) => { + const body = res.body; + const failures = body.response?.failures ?? []; + return Either.right({ + completed: body.completed, + // @ts-expect-error @elastic/elasticsearch GetTaskResponse doesn't declare `error` property + error: Option.fromNullable(body.error), + failures: failures.length > 0 ? Option.some(failures) : Option.none, + description: body.task.description, + }); + }) + .catch(catchWaitForTaskCompletionTimeout) + .catch(catchRetryableEsClientErrors); +}; From e4f74471ecdbf2723581f38b7a5088360b353338 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 3 Jun 2021 12:57:21 -0400 Subject: [PATCH 50/77] [Fleet] Rename config value agents.elasticsearch.host => agents.elasticsearch.hosts (#101162) --- docs/settings/fleet-settings.asciidoc | 4 +- x-pack/plugins/fleet/common/types/index.ts | 2 +- .../fleet/mock/plugin_configuration.ts | 2 +- x-pack/plugins/fleet/server/index.ts | 19 ++++- .../fleet/server/services/output.test.ts | 85 +++++++++++++++++++ .../plugins/fleet/server/services/output.ts | 24 ++++-- 6 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/output.test.ts diff --git a/docs/settings/fleet-settings.asciidoc b/docs/settings/fleet-settings.asciidoc index 9c054fbc002223..134d9de3f49d88 100644 --- a/docs/settings/fleet-settings.asciidoc +++ b/docs/settings/fleet-settings.asciidoc @@ -39,8 +39,8 @@ See the {fleet-guide}/index.html[{fleet}] docs for more information. |=== | `xpack.fleet.agents.fleet_server.hosts` | Hostnames used by {agent} for accessing {fleet-server}. -| `xpack.fleet.agents.elasticsearch.host` - | The hostname used by {agent} for accessing {es}. +| `xpack.fleet.agents.elasticsearch.hosts` + | Hostnames used by {agent} for accessing {es}. |=== [NOTE] diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index 7117973baa1393..95f91165aaf94e 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -16,7 +16,7 @@ export interface FleetConfigType { agents: { enabled: boolean; elasticsearch: { - host?: string; + hosts?: string[]; ca_sha256?: string; }; fleet_server?: { diff --git a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts index 7f0b71de779dc6..a9ad6b1bd87941 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/mock/plugin_configuration.ts @@ -15,7 +15,7 @@ export const createConfigurationMock = (): FleetConfigType => { agents: { enabled: true, elasticsearch: { - host: '', + hosts: [''], ca_sha256: '', }, }, diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index e83617413b7442..0a886ffedbd6c0 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -41,6 +41,23 @@ export const config: PluginConfigDescriptor = { unused('agents.pollingRequestTimeout'), unused('agents.tlsCheckDisabled'), unused('agents.fleetServerEnabled'), + (fullConfig, fromPath, addDeprecation) => { + const oldValue = fullConfig?.xpack?.fleet?.agents?.elasticsearch?.host; + if (oldValue) { + delete fullConfig.xpack.fleet.agents.elasticsearch.host; + fullConfig.xpack.fleet.agents.elasticsearch.hosts = [oldValue]; + addDeprecation({ + message: `Config key [xpack.fleet.agents.elasticsearch.host] is deprecated and replaced by [xpack.fleet.agents.elasticsearch.hosts]`, + correctiveActions: { + manualSteps: [ + `Use [xpack.fleet.agents.elasticsearch.hosts] with an array of host instead.`, + ], + }, + }); + } + + return fullConfig; + }, ], schema: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -49,7 +66,7 @@ export const config: PluginConfigDescriptor = { agents: schema.object({ enabled: schema.boolean({ defaultValue: true }), elasticsearch: schema.object({ - host: schema.maybe(schema.string()), + hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), ca_sha256: schema.maybe(schema.string()), }), fleet_server: schema.maybe( diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts new file mode 100644 index 00000000000000..26e3955607adaf --- /dev/null +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -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 { outputService } from './output'; + +import { appContextService } from './app_context'; + +jest.mock('./app_context'); + +const mockedAppContextService = appContextService as jest.Mocked; + +const CLOUD_ID = + 'dXMtZWFzdC0xLmF3cy5mb3VuZC5pbyRjZWM2ZjI2MWE3NGJmMjRjZTMzYmI4ODExYjg0Mjk0ZiRjNmMyY2E2ZDA0MjI0OWFmMGNjN2Q3YTllOTYyNTc0Mw=='; + +const CONFIG_WITH_ES_HOSTS = { + enabled: true, + agents: { + enabled: true, + elasticsearch: { + hosts: ['http://host1.com'], + }, + }, +}; + +const CONFIG_WITHOUT_ES_HOSTS = { + enabled: true, + agents: { + enabled: true, + elasticsearch: {}, + }, +}; + +describe('Output Service', () => { + describe('getDefaultESHosts', () => { + afterEach(() => { + mockedAppContextService.getConfig.mockReset(); + mockedAppContextService.getConfig.mockReset(); + }); + it('Should use cloud ID as the source of truth for ES hosts', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: true, + cloudId: CLOUD_ID, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITH_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual([ + 'https://cec6f261a74bf24ce33bb8811b84294f.us-east-1.aws.found.io:443', + ]); + }); + + it('Should use the value from the config if not in cloud', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: false, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITH_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual(['http://host1.com']); + }); + + it('Should use the default value if there is no config', () => { + // @ts-expect-error + mockedAppContextService.getCloud.mockReturnValue({ + isCloudEnabled: false, + }); + + mockedAppContextService.getConfig.mockReturnValue(CONFIG_WITHOUT_ES_HOSTS); + + const hosts = outputService.getDefaultESHosts(); + + expect(hosts).toEqual(['http://localhost:9200']); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index b3857ba5c0ef37..0c7b086f78fdf8 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -16,6 +16,8 @@ import { normalizeHostsForAgents } from './hosts_utils'; const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE; +const DEFAULT_ES_HOSTS = ['http://localhost:9200']; + class OutputService { public async getDefaultOutput(soClient: SavedObjectsClientContract) { return await soClient.find({ @@ -27,17 +29,11 @@ class OutputService { public async ensureDefaultOutput(soClient: SavedObjectsClientContract) { const outputs = await this.getDefaultOutput(soClient); - const cloud = appContextService.getCloud(); - const cloudId = cloud?.isCloudEnabled && cloud.cloudId; - const cloudUrl = cloudId && decodeCloudId(cloudId)?.elasticsearchUrl; - const flagsUrl = appContextService.getConfig()!.agents.elasticsearch.host; - const defaultUrl = 'http://localhost:9200'; - const defaultOutputUrl = cloudUrl || flagsUrl || defaultUrl; if (!outputs.saved_objects.length) { const newDefaultOutput = { ...DEFAULT_OUTPUT, - hosts: [defaultOutputUrl], + hosts: this.getDefaultESHosts(), ca_sha256: appContextService.getConfig()!.agents.elasticsearch.ca_sha256, } as NewOutput; @@ -50,6 +46,20 @@ class OutputService { }; } + public getDefaultESHosts(): string[] { + const cloud = appContextService.getCloud(); + const cloudId = cloud?.isCloudEnabled && cloud.cloudId; + const cloudUrl = cloudId && decodeCloudId(cloudId)?.elasticsearchUrl; + const cloudHosts = cloudUrl ? [cloudUrl] : undefined; + const flagHosts = + appContextService.getConfig()!.agents?.elasticsearch?.hosts && + appContextService.getConfig()!.agents.elasticsearch.hosts?.length + ? appContextService.getConfig()!.agents.elasticsearch.hosts + : undefined; + + return cloudHosts || flagHosts || DEFAULT_ES_HOSTS; + } + public async getDefaultOutputId(soClient: SavedObjectsClientContract) { const outputs = await this.getDefaultOutput(soClient); From 747b80b58ff054ca2a0a00824e7b3911a9268f55 Mon Sep 17 00:00:00 2001 From: Dan Panzarella Date: Thu, 3 Jun 2021 14:51:45 -0400 Subject: [PATCH 51/77] [Security Solution] [OLM] Endpoint pending actions API (#101269) --- .../common/endpoint/constants.ts | 1 + .../common/endpoint/schema/actions.ts | 9 + .../common/endpoint/types/actions.ts | 24 +- .../server/endpoint/routes/actions/index.ts | 18 +- .../endpoint/routes/actions/isolation.test.ts | 1 - .../endpoint/routes/actions/isolation.ts | 8 +- .../server/endpoint/routes/actions/mocks.ts | 140 +++++++++ .../endpoint/routes/actions/status.test.ts | 276 ++++++++++++++++++ .../server/endpoint/routes/actions/status.ts | 128 ++++++++ .../security_solution/server/plugin.ts | 8 +- 10 files changed, 592 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index cdfc34c2e9cda2..f4cf85e0252378 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -36,3 +36,4 @@ export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; /** Endpoint Actions Log Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; +export const ACTION_STATUS_ROUTE = `/api/endpoint/action_status`; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index 32affddf462949..09776b57ed8eaf 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -28,3 +28,12 @@ export const EndpointActionLogRequestSchema = { agent_id: schema.string(), }), }; + +export const ActionStatusRequestSchema = { + query: schema.object({ + agent_ids: schema.oneOf([ + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1, maxSize: 50 }), + schema.string({ minLength: 1 }), + ]), + }), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index fcfda9c9a30d94..3c9be9a823c498 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -10,6 +10,11 @@ import { HostIsolationRequestSchema } from '../schema/actions'; export type ISOLATION_ACTIONS = 'isolate' | 'unisolate'; +export interface EndpointActionData { + command: ISOLATION_ACTIONS; + comment?: string; +} + export interface EndpointAction { action_id: string; '@timestamp': string; @@ -18,10 +23,7 @@ export interface EndpointAction { input_type: 'endpoint'; agents: string[]; user_id: string; - data: { - command: ISOLATION_ACTIONS; - comment?: string; - }; + data: EndpointActionData; } export interface EndpointActionResponse { @@ -32,11 +34,8 @@ export interface EndpointActionResponse { agent_id: string; started_at: string; completed_at: string; - error: string; - action_data: { - command: ISOLATION_ACTIONS; - comment?: string; - }; + error?: string; + action_data: EndpointActionData; } export type HostIsolationRequestBody = TypeOf; @@ -44,3 +43,10 @@ export type HostIsolationRequestBody = TypeOf { }, ]) ); - endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); licenseEmitter = new Subject(); licenseService = new LicenseService(); licenseService.start(licenseEmitter); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 68420411284651..9dacc9767b88b9 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -117,7 +117,7 @@ export const isolationRequestHandler = function ( const actionID = uuid.v4(); let result; try { - result = await esClient.index({ + result = await esClient.index({ index: AGENT_ACTIONS_INDEX, body: { action_id: actionID, @@ -126,12 +126,12 @@ export const isolationRequestHandler = function ( type: 'INPUT_ACTION', input_type: 'endpoint', agents: agentIDs, - user_id: user?.username, + user_id: user!.username, data: { command: isolate ? 'isolate' : 'unisolate', - comment: req.body.comment, + comment: req.body.comment ?? undefined, }, - } as EndpointAction, + }, }); } catch (e) { return res.customError({ diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts new file mode 100644 index 00000000000000..34f7d140a78de4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts @@ -0,0 +1,140 @@ +/* + * 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/no-explicit-any */ +/* eslint-disable max-classes-per-file */ +/* eslint-disable @typescript-eslint/no-useless-constructor */ + +import { ApiResponse } from '@elastic/elasticsearch'; +import moment from 'moment'; +import uuid from 'uuid'; +import { + EndpointAction, + EndpointActionResponse, + ISOLATION_ACTIONS, +} from '../../../../common/endpoint/types'; + +export const mockSearchResult = (results: any = []): ApiResponse => { + return { + body: { + hits: { + hits: results.map((a: any) => ({ + _source: a, + })), + }, + }, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + }; +}; + +export class MockAction { + private actionID: string = uuid.v4(); + private ts: moment.Moment = moment(); + private user: string = ''; + private agents: string[] = []; + private command: ISOLATION_ACTIONS = 'isolate'; + private comment?: string; + + constructor() {} + + public build(): EndpointAction { + return { + action_id: this.actionID, + '@timestamp': this.ts.toISOString(), + expiration: this.ts.add(2, 'weeks').toISOString(), + type: 'INPUT_ACTION', + input_type: 'endpoint', + agents: this.agents, + user_id: this.user, + data: { + command: this.command, + comment: this.comment, + }, + }; + } + + public fromUser(u: string) { + this.user = u; + return this; + } + public withAgents(a: string[]) { + this.agents = a; + return this; + } + public withAgent(a: string) { + this.agents = [a]; + return this; + } + public withComment(c: string) { + this.comment = c; + return this; + } + public withAction(a: ISOLATION_ACTIONS) { + this.command = a; + return this; + } + public atTime(m: moment.Moment | Date) { + if (m instanceof Date) { + this.ts = moment(m); + } else { + this.ts = m; + } + return this; + } + public withID(id: string) { + this.actionID = id; + return this; + } +} + +export const aMockAction = (): MockAction => { + return new MockAction(); +}; + +export class MockResponse { + private actionID: string = uuid.v4(); + private ts: moment.Moment = moment(); + private started: moment.Moment = moment(); + private completed: moment.Moment = moment(); + private agent: string = ''; + private command: ISOLATION_ACTIONS = 'isolate'; + private comment?: string; + private error?: string; + + constructor() {} + + public build(): EndpointActionResponse { + return { + '@timestamp': this.ts.toISOString(), + action_id: this.actionID, + agent_id: this.agent, + started_at: this.started.toISOString(), + completed_at: this.completed.toISOString(), + error: this.error, + action_data: { + command: this.command, + comment: this.comment, + }, + }; + } + + public forAction(id: string) { + this.actionID = id; + return this; + } + public forAgent(id: string) { + this.agent = id; + return this; + } +} + +export const aMockResponse = (actionID: string, agentID: string): MockResponse => { + return new MockResponse().forAction(actionID).forAgent(agentID); +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts new file mode 100644 index 00000000000000..62e138ead7f815 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.test.ts @@ -0,0 +1,276 @@ +/* + * 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/no-explicit-any */ + +import { KibanaResponseFactory, RequestHandler, RouteConfig } from 'kibana/server'; +import { + elasticsearchServiceMock, + httpServerMock, + httpServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; +import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants'; +import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; +import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; +import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { + createMockEndpointAppContextServiceStartContract, + createRouteHandlerContext, +} from '../../mocks'; +import { registerActionStatusRoutes } from './status'; +import uuid from 'uuid'; +import { aMockAction, aMockResponse, MockAction, mockSearchResult, MockResponse } from './mocks'; + +describe('Endpoint Action Status', () => { + describe('schema', () => { + it('should require at least 1 agent ID', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({}); // no agent_ids provided + }).toThrow(); + }); + + it('should accept a single agent ID', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: uuid.v4() }); + }).not.toThrow(); + }); + + it('should accept multiple agent IDs', () => { + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: [uuid.v4(), uuid.v4()] }); + }).not.toThrow(); + }); + it('should limit the maximum number of agent IDs', () => { + const tooManyCooks = new Array(200).fill(uuid.v4()); // all the same ID string + expect(() => { + ActionStatusRequestSchema.query.validate({ agent_ids: tooManyCooks }); + }).toThrow(); + }); + }); + + describe('response', () => { + let endpointAppContextService: EndpointAppContextService; + + // convenience for calling the route and handler for action status + let getPendingStatus: (reqParams?: any) => Promise>; + // convenience for injecting mock responses for actions index and responses + let havingActionsAndResponses: (actions: MockAction[], responses: any[]) => void; + + beforeEach(() => { + const esClientMock = elasticsearchServiceMock.createScopedClusterClient(); + const routerMock = httpServiceMock.createRouter(); + endpointAppContextService = new EndpointAppContextService(); + endpointAppContextService.start(createMockEndpointAppContextServiceStartContract()); + + registerActionStatusRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + }); + + getPendingStatus = async (reqParams?: any): Promise> => { + const req = httpServerMock.createKibanaRequest(reqParams); + const mockResponse = httpServerMock.createResponseFactory(); + const [, routeHandler]: [ + RouteConfig, + RequestHandler + ] = routerMock.get.mock.calls.find(([{ path }]) => path.startsWith(ACTION_STATUS_ROUTE))!; + await routeHandler( + createRouteHandlerContext(esClientMock, savedObjectsClientMock.create()), + req, + mockResponse + ); + + return mockResponse; + }; + + havingActionsAndResponses = (actions: MockAction[], responses: MockResponse[]) => { + esClientMock.asCurrentUser.search = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve(mockSearchResult(actions.map((a) => a.build()))) + ) + .mockImplementationOnce(() => + Promise.resolve(mockSearchResult(responses.map((r) => r.build()))) + ); + }; + }); + + afterEach(() => { + endpointAppContextService.stop(); + }); + + it('should include agent IDs in the output, even if they have no actions', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses([], []); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + }); + + it('should respond with a valid pending action', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses([aMockAction().withAgent(mockID)], []); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + }); + it('should include a total count of a pending action', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 2 + ); + }); + it('should show multiple pending actions, and their counts', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('isolate'), + aMockAction().withAgent(mockID).withAction('unisolate'), + aMockAction().withAgent(mockID).withAction('unisolate'), + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 3 + ); + expect( + (response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.unisolate + ).toEqual(2); + }); + it('should calculate correct pending counts from grouped/bulked actions', async () => { + const mockID = 'XYZABC-000'; + havingActionsAndResponses( + [ + aMockAction() + .withAgents([mockID, 'IRRELEVANT-OTHER-AGENT', 'ANOTHER-POSSIBLE-AGENT']) + .withAction('isolate'), + aMockAction().withAgents([mockID, 'YET-ANOTHER-AGENT-ID']).withAction('isolate'), + aMockAction().withAgents(['YET-ANOTHER-AGENT-ID']).withAction('isolate'), // one WITHOUT our agent-under-test + ], + [] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 2 + ); + }); + + it('should exclude actions that have responses from the pending count', async () => { + const mockAgentID = 'XYZABC-000'; + const actionID = 'some-known-actionid'; + havingActionsAndResponses( + [ + aMockAction().withAgent(mockAgentID).withAction('isolate'), + aMockAction().withAgent(mockAgentID).withAction('isolate').withID(actionID), + ], + [aMockResponse(actionID, mockAgentID)] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [mockAgentID], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(1); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].agent_id).toEqual(mockAgentID); + expect((response.ok.mock.calls[0][0]?.body as any)?.data[0].pending_actions.isolate).toEqual( + 1 + ); + }); + + it('should have accurate counts for multiple agents, bulk actions, and responses', async () => { + const agentOne = 'XYZABC-000'; + const agentTwo = 'DEADBEEF'; + const agentThree = 'IDIDIDID'; + + const actionTwoID = 'ID-TWO'; + havingActionsAndResponses( + [ + aMockAction().withAgents([agentOne, agentTwo, agentThree]).withAction('isolate'), + aMockAction() + .withAgents([agentTwo, agentThree]) + .withAction('isolate') + .withID(actionTwoID), + aMockAction().withAgents([agentThree]).withAction('isolate'), + ], + [aMockResponse(actionTwoID, agentThree)] + ); + const response = await getPendingStatus({ + query: { + agent_ids: [agentOne, agentTwo, agentThree], + }, + }); + expect(response.ok).toBeCalled(); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toHaveLength(3); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentOne, + pending_actions: { + isolate: 1, + }, + }); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentTwo, + pending_actions: { + isolate: 2, + }, + }); + expect((response.ok.mock.calls[0][0]?.body as any)?.data).toContainEqual({ + agent_id: agentThree, + pending_actions: { + isolate: 2, // present in all three actions, but second one has a response, therefore not pending + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts new file mode 100644 index 00000000000000..faaf41962a96cf --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.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 { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { + EndpointAction, + EndpointActionResponse, + PendingActionsResponse, +} from '../../../../common/endpoint/types'; +import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; +import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants'; +import { + SecuritySolutionPluginRouter, + SecuritySolutionRequestHandlerContext, +} from '../../../types'; +import { EndpointAppContext } from '../../types'; + +/** + * Registers routes for checking status of endpoints based on pending actions + */ +export function registerActionStatusRoutes( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) { + router.get( + { + path: ACTION_STATUS_ROUTE, + validate: ActionStatusRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + actionStatusRequestHandler(endpointContext) + ); +} + +export const actionStatusRequestHandler = function ( + endpointContext: EndpointAppContext +): RequestHandler< + unknown, + TypeOf, + unknown, + SecuritySolutionRequestHandlerContext +> { + return async (context, req, res) => { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const agentIDs: string[] = Array.isArray(req.query.agent_ids) + ? [...new Set(req.query.agent_ids)] + : [req.query.agent_ids]; + + // retrieve the unexpired actions for the given hosts + const recentActionResults = await esClient.search( + { + index: AGENT_ACTIONS_INDEX, + body: { + query: { + bool: { + filter: [ + { term: { type: 'INPUT_ACTION' } }, // actions that are directed at agent children + { term: { input_type: 'endpoint' } }, // filter for agent->endpoint actions + { range: { expiration: { gte: 'now' } } }, // that have not expired yet + { terms: { agents: agentIDs } }, // for the requested agent IDs + ], + }, + }, + }, + }, + { + ignore: [404], + } + ); + const pendingActions = + recentActionResults.body?.hits?.hits?.map((a): EndpointAction => a._source!) || []; + + // retrieve any responses to those action IDs from these agents + const actionIDs = pendingActions.map((a) => a.action_id); + const responseResults = await esClient.search( + { + index: '.fleet-actions-results', + body: { + query: { + bool: { + filter: [ + { terms: { action_id: actionIDs } }, // get results for these actions + { terms: { agent_id: agentIDs } }, // ignoring responses from agents we're not looking for + ], + }, + }, + }, + }, + { + ignore: [404], + } + ); + const actionResponses = responseResults.body?.hits?.hits?.map((a) => a._source!) || []; + + // respond with action-count per agent + const response = agentIDs.map((aid) => { + const responseIDsFromAgent = actionResponses + .filter((r) => r.agent_id === aid) + .map((r) => r.action_id); + return { + agent_id: aid, + pending_actions: pendingActions + .filter((a) => a.agents.includes(aid) && !responseIDsFromAgent.includes(a.action_id)) + .map((a) => a.data.command) + .reduce((acc, cur) => { + if (cur in acc) { + acc[cur] += 1; + } else { + acc[cur] = 1; + } + return acc; + }, {} as PendingActionsResponse['pending_actions']), + } as PendingActionsResponse; + }); + + return res.ok({ + body: { + data: response, + }, + }); + }; +}; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 732ae482234213..5aa298d6789be7 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -75,10 +75,7 @@ import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; -import { - registerHostIsolationRoutes, - registerActionAuditLogRoutes, -} from './endpoint/routes/actions'; +import { registerActionRoutes } from './endpoint/routes/actions'; import { EndpointArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; @@ -293,8 +290,7 @@ export class Plugin implements IPlugin Date: Thu, 3 Jun 2021 13:05:03 -0700 Subject: [PATCH 52/77] [DOCS] Updates videos in Maps and Discover (#101312) --- docs/maps/index.asciidoc | 2 +- docs/user/introduction.asciidoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/maps/index.asciidoc b/docs/maps/index.asciidoc index 45d24bfb5a7e4f..e4f72b344b844c 100644 --- a/docs/maps/index.asciidoc +++ b/docs/maps/index.asciidoc @@ -24,7 +24,7 @@ Create beautiful maps from your geographical data. With **Maps**, you can: Date: Thu, 3 Jun 2021 15:15:15 -0500 Subject: [PATCH 53/77] [DOCS] Adds the updated Lens video (#101327) --- docs/user/dashboard/lens.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/dashboard/lens.asciidoc b/docs/user/dashboard/lens.asciidoc index c5718b2a089bfc..3b3a7a9ee527d4 100644 --- a/docs/user/dashboard/lens.asciidoc +++ b/docs/user/dashboard/lens.asciidoc @@ -10,8 +10,8 @@ src="https://play.vidyard.com/embed/v4.js"> From 78d8272afebe3fe89fd121f559a217e3969ba4f2 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 3 Jun 2021 21:26:17 +0100 Subject: [PATCH 54/77] chore(NA): moving @kbn/rule-data-utils into bazel (#101290) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monorepo-packages.asciidoc | 1 + package.json | 2 +- packages/BUILD.bazel | 1 + packages/kbn-rule-data-utils/BUILD.bazel | 79 +++++++++++++++++++ packages/kbn-rule-data-utils/tsconfig.json | 3 +- yarn.lock | 2 +- 6 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 packages/kbn-rule-data-utils/BUILD.bazel diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index dbfbe90ec9263e..029ee9ea4faf6c 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -85,6 +85,7 @@ yarn kbn watch-bazel - @kbn/logging - @kbn/mapbox-gl - @kbn/monaco +- @kbn/rule-data-utils - @kbn/securitysolution-es-utils - @kbn/securitysolution-io-ts-alerting-types - @kbn/securitysolution-io-ts-list-types diff --git a/package.json b/package.json index f0803b3b440569..a2499d85247d73 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,7 @@ "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging", "@kbn/logging": "link:bazel-bin/packages/kbn-logging", "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco", - "@kbn/rule-data-utils": "link:packages/kbn-rule-data-utils", + "@kbn/rule-data-utils": "link:bazel-bin/packages/kbn-rule-data-utils", "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants", "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils", "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index de3498da1a6976..083ae90a031f50 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -28,6 +28,7 @@ filegroup( "//packages/kbn-mapbox-gl:build", "//packages/kbn-monaco:build", "//packages/kbn-plugin-generator:build", + "//packages/kbn-rule-data-utils:build", "//packages/kbn-securitysolution-list-constants:build", "//packages/kbn-securitysolution-io-ts-types:build", "//packages/kbn-securitysolution-io-ts-alerting-types:build", diff --git a/packages/kbn-rule-data-utils/BUILD.bazel b/packages/kbn-rule-data-utils/BUILD.bazel new file mode 100644 index 00000000000000..ccd1793feb161e --- /dev/null +++ b/packages/kbn-rule-data-utils/BUILD.bazel @@ -0,0 +1,79 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-rule-data-utils" +PKG_REQUIRE_NAME = "@kbn/rule-data-utils" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +SRC_DEPS = [ + "@npm//tslib", + "@npm//utility-types", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_map = True, + incremental = True, + out_dir = "target", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-rule-data-utils/tsconfig.json b/packages/kbn-rule-data-utils/tsconfig.json index 4b1262d11f3aff..852393f01e5941 100644 --- a/packages/kbn-rule-data-utils/tsconfig.json +++ b/packages/kbn-rule-data-utils/tsconfig.json @@ -1,11 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "incremental": true, "outDir": "./target", "stripInternal": false, "declaration": true, "declarationMap": true, + "rootDir": "./src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-rule-data-utils/src", "types": [ diff --git a/yarn.lock b/yarn.lock index 7f2b44b5d0c3fe..c5255bc4d0d305 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2703,7 +2703,7 @@ version "0.0.0" uid "" -"@kbn/rule-data-utils@link:packages/kbn-rule-data-utils": +"@kbn/rule-data-utils@link:bazel-bin/packages/kbn-rule-data-utils": version "0.0.0" uid "" From b1f2b1f9f5b48fd9008024923c7eeda30a124d16 Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Thu, 3 Jun 2021 13:36:45 -0700 Subject: [PATCH 55/77] [DOCS] Updates video in Intor & Maps take 2 (#101330) --- docs/maps/index.asciidoc | 2 +- docs/user/introduction.asciidoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/maps/index.asciidoc b/docs/maps/index.asciidoc index e4f72b344b844c..20320c5a938c9e 100644 --- a/docs/maps/index.asciidoc +++ b/docs/maps/index.asciidoc @@ -25,7 +25,7 @@ Create beautiful maps from your geographical data. With **Maps**, you can: style="width: 100%; margin: auto; display: block;" class="vidyard-player-embed" src="https://play.vidyard.com/mBuWenQ2uSLY9YjEkPtzJC.jpg" -data-uuid="BYzRDtH4u7RSD8wKhuEW1b" +data-uuid="mBuWenQ2uSLY9YjEkPtzJC" data-v="4" data-type="inline" /> diff --git a/docs/user/introduction.asciidoc b/docs/user/introduction.asciidoc index d2ea5aaa555ef7..25780d303eec41 100644 --- a/docs/user/introduction.asciidoc +++ b/docs/user/introduction.asciidoc @@ -27,7 +27,7 @@ which features. style="width: 100%; margin: auto; display: block;" class="vidyard-player-embed" src="https://play.vidyard.com/iyqMwJcvi8r4YfjeoPMjyH.jpg" -data-uuid="jW5wP2dmegbs5ThRZ451Gj" +data-uuid="iyqMwJcvi8r4YfjeoPMjyH" data-v="4" data-type="inline" /> From be9fcad655e6aa7abd0411b4d4f138e2936fe17d Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 3 Jun 2021 15:13:11 -0700 Subject: [PATCH 56/77] [fix] import from the root of `@kbn/expect` (#101321) Co-authored-by: spalger --- test/functional/page_objects/error_page.ts | 2 +- test/functional/page_objects/visualize_editor_page.ts | 2 +- .../spaces_only/tests/alerting/update.ts | 2 +- .../api_integration/apis/lists/create_exception_list_item.ts | 2 +- x-pack/test/api_integration/apis/security/api_keys.ts | 2 +- .../test/api_integration/apis/security/builtin_es_privileges.ts | 2 +- x-pack/test/api_integration/apis/security/index_fields.ts | 2 +- x-pack/test/api_integration/apis/security/license_downgrade.ts | 2 +- x-pack/test/api_integration/apis/security/privileges.ts | 2 +- x-pack/test/api_integration/apis/spaces/saved_objects.ts | 2 +- x-pack/test/fleet_api_integration/apis/agents/upgrade.ts | 2 +- x-pack/test/functional/page_objects/infra_home_page.ts | 2 +- x-pack/test/functional/services/uptime/monitor.ts | 2 +- .../test_suites/event_log/public_api_integration.ts | 2 +- .../test_suites/event_log/service_api_integration.ts | 2 +- .../test/saved_object_api_integration/common/suites/delete.ts | 2 +- .../security_api_integration/tests/session_idle/extension.ts | 2 +- x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts | 2 +- .../test/security_solution_endpoint_api_int/apis/metadata_v1.ts | 2 +- x-pack/test/security_solution_endpoint_api_int/apis/policy.ts | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index 98096f3179d020..99c17c632720a1 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 9ba1ab6f85081f..d311f752fd4907 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index 3d98e428fd9ee4..326fb0bfac4656 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { Spaces } from '../../scenarios'; import { checkAAD, getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts index fb80d81dd242a4..e18f805bf174ed 100644 --- a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts +++ b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/security/api_keys.ts b/x-pack/test/api_integration/apis/security/api_keys.ts index d2614abc9e5f7b..98ef83c4378634 100644 --- a/x-pack/test/api_integration/apis/security/api_keys.ts +++ b/x-pack/test/api_integration/apis/security/api_keys.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts index c927d095b88897..89587fe2596837 100644 --- a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts +++ b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/index_fields.ts b/x-pack/test/api_integration/apis/security/index_fields.ts index 3f036bcd7f7ea7..c4dc288b0e0601 100644 --- a/x-pack/test/api_integration/apis/security/index_fields.ts +++ b/x-pack/test/api_integration/apis/security/index_fields.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/license_downgrade.ts b/x-pack/test/api_integration/apis/security/license_downgrade.ts index 7a5ad1ce64a62a..dcdcc039bc9d64 100644 --- a/x-pack/test/api_integration/apis/security/license_downgrade.ts +++ b/x-pack/test/api_integration/apis/security/license_downgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index d6ad5f6cd387be..5830fc2d1017fa 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -7,7 +7,7 @@ import util from 'util'; import { isEqual, isEqualWith } from 'lodash'; -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { RawKibanaPrivileges } from '../../../../plugins/security/common/model'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/spaces/saved_objects.ts b/x-pack/test/api_integration/apis/spaces/saved_objects.ts index 20fc3428bb2b16..806929e67ebbcc 100644 --- a/x-pack/test/api_integration/apis/spaces/saved_objects.ts +++ b/x-pack/test/api_integration/apis/spaces/saved_objects.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts index 0722edbcb45b3e..143dc123bc7229 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/upgrade.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import semver from 'semver'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { setupFleetAndAgents } from './services'; diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index a5388aa829d01c..8dfef36c3a1c11 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import testSubjSelector from '@kbn/test-subj-selector'; import { FtrProviderContext } from '../ftr_provider_context'; diff --git a/x-pack/test/functional/services/uptime/monitor.ts b/x-pack/test/functional/services/uptime/monitor.ts index 3b22a5f7f6630b..583a37d7f4ef9a 100644 --- a/x-pack/test/functional/services/uptime/monitor.ts +++ b/x-pack/test/functional/services/uptime/monitor.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeMonitorProvider({ getService, getPageObjects }: FtrProviderContext) { diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts index f2497041094f74..d41dab2741cd59 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts @@ -7,7 +7,7 @@ import { merge, omit, chunk, isEmpty } from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; import { IEvent } from '../../../../plugins/event_log/server'; diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts index 170b01a01edf92..960deb692ac8dc 100644 --- a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import uuid from 'uuid'; -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { IEvent } from '../../../../plugins/event_log/server'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index 1ba8ea32b99224..9726c47a9bc0a6 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -6,7 +6,7 @@ */ import { SuperTest } from 'supertest'; -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; diff --git a/x-pack/test/security_api_integration/tests/session_idle/extension.ts b/x-pack/test/security_api_integration/tests/session_idle/extension.ts index b8fef972f05d6b..84ab8ce42c13ef 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/extension.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/extension.ts @@ -6,7 +6,7 @@ */ import { Cookie, cookie } from 'request'; -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index da339f54d41f46..a1be11c4f696d1 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteAllDocsFromMetadataCurrentIndex, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts index 1e1322944153bd..6879184b9bc13e 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deleteMetadataStream } from './data_stream_helper'; import { METADATA_REQUEST_V1_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts index 318e857bdcad0b..73687784d15eaf 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/policy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import expect from '@kbn/expect/expect'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { deletePolicyStream } from './data_stream_helper'; From caa4bd111d2d2f9c318cd6f492990713a5d463fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Fri, 4 Jun 2021 02:59:30 +0200 Subject: [PATCH 57/77] [APM] Add Obs side nav and refactor APM templates (#101044) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apm/common/environment_filter_values.ts | 12 + .../step_definitions/csm/csm_dashboard.ts | 16 +- .../public/application/application.test.tsx | 23 +- .../plugins/apm/public/application/csmApp.tsx | 4 +- .../plugins/apm/public/application/index.tsx | 85 +--- .../alerting/alerting_flyout/index.tsx | 15 +- .../DetailView/index.test.tsx | 39 +- .../ErrorGroupDetails/DetailView/index.tsx | 4 +- .../app/ErrorGroupDetails/index.tsx | 163 +++--- .../public/components/app/Home/Home.test.tsx | 33 -- .../app/Home/__snapshots__/Home.test.tsx.snap | 189 ------- .../apm/public/components/app/Home/index.tsx | 80 --- .../app/Main/route_config/index.tsx | 369 -------------- .../Settings/AgentConfigurations/index.tsx | 6 +- .../app/Settings/ApmIndices/index.test.tsx | 6 +- .../app/Settings/ApmIndices/index.tsx | 6 +- .../app/Settings/CustomizeUI/index.tsx | 6 +- .../app/Settings/anomaly_detection/index.tsx | 6 +- .../public/components/app/Settings/index.tsx | 166 +++--- .../app/error_group_overview/index.tsx | 63 ++- .../components/app/service_details/index.tsx | 39 -- .../service_details/service_detail_tabs.tsx | 202 -------- .../app/service_inventory/index.tsx | 46 +- .../Controls.test.tsx | 0 .../{ServiceMap => service_map}/Controls.tsx | 0 .../{ServiceMap => service_map}/Cytoscape.tsx | 0 .../EmptyBanner.tsx | 0 .../Popover/AnomalyDetection.tsx | 0 .../Popover/Buttons.test.tsx | 0 .../Popover/Buttons.tsx | 0 .../Popover/Contents.tsx | 0 .../Popover/Info.tsx | 0 .../Popover/Popover.stories.tsx | 4 +- .../Popover/ServiceStatsFetcher.tsx | 0 .../Popover/ServiceStatsList.tsx | 0 .../Popover/index.tsx | 0 .../Popover/service_stats_list.stories.tsx | 2 +- .../__stories__/Cytoscape.stories.tsx | 2 +- .../__stories__/centerer.tsx | 0 .../cytoscape_example_data.stories.tsx | 4 +- .../example_grouped_connections.json | 0 .../example_response_hipster_store.json | 0 .../example_response_opbeans_beats.json | 0 .../__stories__/example_response_todo.json | 0 .../generate_service_map_elements.ts | 0 .../cytoscape_options.ts | 0 .../empty_banner.test.tsx | 0 .../empty_prompt.tsx | 0 .../app/{ServiceMap => service_map}/icons.ts | 0 .../index.test.tsx | 2 +- .../app/{ServiceMap => service_map}/index.tsx | 24 +- .../timeout_prompt.tsx | 0 .../useRefDimensions.ts | 0 .../use_cytoscape_event_handlers.test.tsx | 0 .../use_cytoscape_event_handlers.ts | 0 .../components/app/service_metrics/index.tsx | 61 +-- .../app/service_node_metrics/index.test.tsx | 11 +- .../app/service_node_metrics/index.tsx | 66 +-- .../app/service_node_overview/index.tsx | 37 +- .../components/app/service_overview/index.tsx | 143 +++--- .../get_columns.tsx | 1 + .../intance_details.tsx | 5 +- .../app/service_profiling/index.tsx | 82 ++- .../components/app/trace_overview/index.tsx | 19 +- .../WaterfallWithSummmary/TransactionTabs.tsx | 11 +- .../Waterfall/WaterfallFlyout.tsx | 16 +- .../Waterfall/accordion_waterfall.tsx | 4 - .../WaterfallContainer/Waterfall/index.tsx | 18 +- .../WaterfallContainer.stories.tsx | 5 - .../WaterfallContainer/index.tsx | 4 - .../WaterfallWithSummmary/index.tsx | 4 - .../app/transaction_details/index.tsx | 99 ++-- .../app/transaction_overview/index.tsx | 106 ++-- .../transaction_overview.test.tsx | 42 +- .../components/routing/apm_route_config.tsx | 478 ++++++++++++++++++ .../public/components/routing/app_root.tsx | 110 ++++ .../public/components/routing/redirect_to.tsx | 38 ++ .../route_config.test.tsx | 4 +- .../route_handlers/agent_configuration.tsx | 8 +- .../routing/templates/apm_main_template.tsx | 43 ++ .../templates/apm_service_template.tsx | 216 ++++++++ .../shared/ApmHeader/apm_header.stories.tsx | 53 -- .../components/shared/ApmHeader/index.tsx | 36 -- .../shared/EnvironmentFilter/index.tsx | 9 +- .../alerting_popover_flyout.tsx | 6 +- .../anomaly_detection_setup_link.test.tsx | 4 +- .../anomaly_detection_setup_link.tsx | 18 +- .../shared/apm_header_action_menu}/index.tsx | 8 +- .../public/components/shared/main_tabs.tsx | 24 - .../components/shared/search_bar.test.tsx | 120 +++++ .../public/components/shared/search_bar.tsx | 32 +- .../service_icons/alert_details.tsx | 12 +- .../service_icons/cloud_details.tsx | 2 +- .../service_icons/container_details.tsx | 4 +- .../service_icons/icon_popover.tsx | 4 +- .../service_icons/index.test.tsx | 10 +- .../service_icons/index.tsx | 12 +- .../service_icons/service_details.tsx | 2 +- .../apm/public/hooks/use_breadcrumbs.test.tsx | 6 +- x-pack/plugins/apm/public/plugin.ts | 28 +- .../public/utils/getRangeFromTimeSeries.ts | 22 - .../translations/translations/ja-JP.json | 18 - .../translations/translations/zh-CN.json | 18 - 103 files changed, 1640 insertions(+), 2055 deletions(-) delete mode 100644 x-pack/plugins/apm/public/components/app/Home/Home.test.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap delete mode 100644 x-pack/plugins/apm/public/components/app/Home/index.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/service_details/index.tsx delete mode 100644 x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Controls.test.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Controls.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Cytoscape.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/EmptyBanner.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/AnomalyDetection.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/Buttons.test.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/Buttons.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/Contents.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/Info.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/Popover.stories.tsx (97%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/ServiceStatsFetcher.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/ServiceStatsList.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/index.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/Popover/service_stats_list.stories.tsx (96%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/Cytoscape.stories.tsx (99%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/centerer.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/cytoscape_example_data.stories.tsx (98%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/example_grouped_connections.json (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/example_response_hipster_store.json (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/example_response_opbeans_beats.json (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/example_response_todo.json (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/__stories__/generate_service_map_elements.ts (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/cytoscape_options.ts (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/empty_banner.test.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/empty_prompt.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/icons.ts (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/index.test.tsx (99%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/index.tsx (87%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/timeout_prompt.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/useRefDimensions.ts (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/use_cytoscape_event_handlers.test.tsx (100%) rename x-pack/plugins/apm/public/components/app/{ServiceMap => service_map}/use_cytoscape_event_handlers.ts (100%) create mode 100644 x-pack/plugins/apm/public/components/routing/apm_route_config.tsx create mode 100644 x-pack/plugins/apm/public/components/routing/app_root.tsx create mode 100644 x-pack/plugins/apm/public/components/routing/redirect_to.tsx rename x-pack/plugins/apm/public/components/{app/Main/route_config => routing}/route_config.test.tsx (93%) rename x-pack/plugins/apm/public/components/{app/Main/route_config => routing}/route_handlers/agent_configuration.tsx (86%) create mode 100644 x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx create mode 100644 x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx delete mode 100644 x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx delete mode 100644 x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx rename x-pack/plugins/apm/public/{application/action_menu => components/shared/apm_header_action_menu}/alerting_popover_flyout.tsx (96%) rename x-pack/plugins/apm/public/{application/action_menu => components/shared/apm_header_action_menu}/anomaly_detection_setup_link.test.tsx (95%) rename x-pack/plugins/apm/public/{application/action_menu => components/shared/apm_header_action_menu}/anomaly_detection_setup_link.tsx (83%) rename x-pack/plugins/apm/public/{application/action_menu => components/shared/apm_header_action_menu}/index.tsx (88%) delete mode 100644 x-pack/plugins/apm/public/components/shared/main_tabs.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/search_bar.test.tsx rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/alert_details.tsx (84%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/cloud_details.tsx (97%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/container_details.tsx (94%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/icon_popover.tsx (93%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/index.test.tsx (95%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/index.tsx (91%) rename x-pack/plugins/apm/public/components/{app/service_details => shared}/service_icons/service_details.tsx (96%) delete mode 100644 x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts diff --git a/x-pack/plugins/apm/common/environment_filter_values.ts b/x-pack/plugins/apm/common/environment_filter_values.ts index c80541ee1ba6ba..e4f0b406076794 100644 --- a/x-pack/plugins/apm/common/environment_filter_values.ts +++ b/x-pack/plugins/apm/common/environment_filter_values.ts @@ -37,6 +37,18 @@ export function getEnvironmentLabel(environment: string) { return environmentLabels[environment] || environment; } +export function omitEsFieldValue({ + esFieldValue, + value, + text, +}: { + esFieldValue?: string; + value: string; + text: string; +}) { + return { value, text }; +} + export function parseEnvironmentUrlParam(environment: string) { if (environment === ENVIRONMENT_ALL_VALUE) { return ENVIRONMENT_ALL; diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts index 47154ee214dc42..cbcb48796a6d4b 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts @@ -51,16 +51,16 @@ Then(`should display percentile for page load chart`, () => { cy.get(pMarkers).eq(3).should('have.text', '95th'); }); -Then(`should display chart legend`, () => { - const chartLegend = 'button.echLegendItem__label'; +// Then(`should display chart legend`, () => { +// const chartLegend = 'button.echLegendItem__label'; - waitForLoadingToFinish(); - cy.get('.euiLoadingChart').should('not.exist'); +// waitForLoadingToFinish(); +// cy.get('.euiLoadingChart').should('not.exist'); - cy.get('[data-cy=pageLoadDist]').within(() => { - cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); - }); -}); +// cy.get('[data-cy=pageLoadDist]').within(() => { +// cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); +// }); +// }); Then(`should display tooltip on hover`, () => { cy.get('.euiLoadingChart').should('not.exist'); diff --git a/x-pack/plugins/apm/public/application/application.test.tsx b/x-pack/plugins/apm/public/application/application.test.tsx index 4ec654a6c0bfda..57285649677dcb 100644 --- a/x-pack/plugins/apm/public/application/application.test.tsx +++ b/x-pack/plugins/apm/public/application/application.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React from 'react'; import { act } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { Observable } from 'rxjs'; @@ -15,6 +16,7 @@ import { renderApp } from './'; import { disableConsoleWarning } from '../utils/testHelpers'; import { dataPluginMock } from 'src/plugins/data/public/mocks'; import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { ApmPluginStartDeps } from '../plugin'; jest.mock('../services/rest/index_pattern', () => ({ createStaticIndexPattern: () => Promise.resolve(undefined), @@ -44,6 +46,7 @@ describe('renderApp', () => { config, observabilityRuleTypeRegistry, } = mockApmPluginContextValue; + const plugins = { licensing: { license$: new Observable() }, triggersActionsUi: { actionTypeRegistry: {}, alertTypeRegistry: {} }, @@ -56,7 +59,7 @@ describe('renderApp', () => { }, }, }; - const params = { + const appMountParameters = { element: document.createElement('div'), history: createMemoryHistory(), setHeaderActionMenu: () => {}, @@ -64,7 +67,16 @@ describe('renderApp', () => { const data = dataPluginMock.createStartContract(); const embeddable = embeddablePluginMock.createStartContract(); - const startDeps = { + + const pluginsStart = ({ + observability: { + navigation: { + registerSections: () => jest.fn(), + PageTemplate: ({ children }: { children: React.ReactNode }) => ( +
hello worlds {children}
+ ), + }, + }, triggersActionsUi: { actionTypeRegistry: {}, alertTypeRegistry: {}, @@ -73,7 +85,8 @@ describe('renderApp', () => { }, data, embeddable, - }; + } as unknown) as ApmPluginStartDeps; + jest.spyOn(window, 'scrollTo').mockReturnValueOnce(undefined); createCallApmApi((core as unknown) as CoreStart); @@ -93,8 +106,8 @@ describe('renderApp', () => { unmount = renderApp({ coreStart: core as any, pluginsSetup: plugins as any, - appMountParameters: params as any, - pluginsStart: startDeps as any, + appMountParameters: appMountParameters as any, + pluginsStart, config, observabilityRuleTypeRegistry, }); diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/csmApp.tsx index 11a2777f47f6a6..ca4f4856894f9e 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/csmApp.tsx @@ -20,7 +20,6 @@ import { useUiSetting$, } from '../../../../../src/plugins/kibana_react/public'; import { APMRouteDefinition } from '../application/routes'; -import { renderAsRedirectTo } from '../components/app/Main/route_config'; import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange'; import { RumHome, UX_LABEL } from '../components/app/RumDashboard/RumHome'; import { ApmPluginContext } from '../context/apm_plugin/apm_plugin_context'; @@ -32,6 +31,7 @@ import { createCallApmApi } from '../services/rest/createCallApmApi'; import { px, units } from '../style/variables'; import { createStaticIndexPattern } from '../services/rest/index_pattern'; import { UXActionMenu } from '../components/app/RumDashboard/ActionMenu'; +import { redirectTo } from '../components/routing/redirect_to'; const CsmMainContainer = euiStyled.div` padding: ${px(units.plus)}; @@ -42,7 +42,7 @@ export const rumRoutes: APMRouteDefinition[] = [ { exact: true, path: '/', - render: renderAsRedirectTo('/ux'), + render: redirectTo('/ux'), breadcrumb: UX_LABEL, }, ]; diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index e2a0bdb6b48b13..9b8d3c7822d3d9 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -5,99 +5,18 @@ * 2.0. */ -import { ApmRoute } from '@elastic/apm-rum-react'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; import ReactDOM from 'react-dom'; -import { Route, Router, Switch } from 'react-router-dom'; import 'react-vis/dist/style.css'; -import { DefaultTheme, ThemeProvider } from 'styled-components'; import type { ObservabilityRuleTypeRegistry } from '../../../observability/public'; -import { euiStyled } from '../../../../../src/plugins/kibana_react/common'; import { ConfigSchema } from '../'; import { AppMountParameters, CoreStart } from '../../../../../src/core/public'; -import { - KibanaContextProvider, - RedirectAppLinks, - useUiSetting$, -} from '../../../../../src/plugins/kibana_react/public'; -import { routes } from '../components/app/Main/route_config'; -import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange'; -import { - ApmPluginContext, - ApmPluginContextValue, -} from '../context/apm_plugin/apm_plugin_context'; -import { LicenseProvider } from '../context/license/license_context'; -import { UrlParamsProvider } from '../context/url_params_context/url_params_context'; -import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; import { createStaticIndexPattern } from '../services/rest/index_pattern'; import { setHelpExtension } from '../setHelpExtension'; import { setReadonlyBadge } from '../updateBadge'; -import { AnomalyDetectionJobsContextProvider } from '../context/anomaly_detection_jobs/anomaly_detection_jobs_context'; - -const MainContainer = euiStyled.div` - height: 100%; -`; - -function App() { - const [darkMode] = useUiSetting$('theme:darkMode'); - - useBreadcrumbs(routes); - - return ( - ({ - ...outerTheme, - eui: darkMode ? euiDarkVars : euiLightVars, - darkMode, - })} - > - - - - {routes.map((route, i) => ( - - ))} - - - - ); -} - -export function ApmAppRoot({ - apmPluginContextValue, - startDeps, -}: { - apmPluginContextValue: ApmPluginContextValue; - startDeps: ApmPluginStartDeps; -}) { - const { appMountParameters, core } = apmPluginContextValue; - const { history } = appMountParameters; - const i18nCore = core.i18n; - - return ( - - - - - - - - - - - - - - - - - - ); -} +import { ApmAppRoot } from '../components/routing/app_root'; /** * This module is rendered asynchronously in the Kibana platform. @@ -141,7 +60,7 @@ export const renderApp = ({ ReactDOM.render( , element ); diff --git a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx index 50788c28999b53..35863d80993944 100644 --- a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx @@ -10,24 +10,17 @@ import { useParams } from 'react-router-dom'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { AlertType } from '../../../../common/alert_types'; import { getInitialAlertValues } from '../get_initial_alert_values'; -import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; +import { ApmPluginStartDeps } from '../../../plugin'; interface Props { addFlyoutVisible: boolean; setAddFlyoutVisibility: React.Dispatch>; alertType: AlertType | null; } -interface KibanaDeps { - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; -} - export function AlertingFlyout(props: Props) { const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; const { serviceName } = useParams<{ serviceName?: string }>(); - const { - services: { triggersActionsUi }, - } = useKibana(); - + const { services } = useKibana(); const initialValues = getInitialAlertValues(alertType, serviceName); const onCloseAddFlyout = useCallback(() => setAddFlyoutVisibility(false), [ @@ -37,7 +30,7 @@ export function AlertingFlyout(props: Props) { const addAlertFlyout = useMemo( () => alertType && - triggersActionsUi.getAddAlertFlyout({ + services.triggersActionsUi.getAddAlertFlyout({ consumer: 'apm', onClose: onCloseAddFlyout, alertTypeId: alertType, @@ -45,7 +38,7 @@ export function AlertingFlyout(props: Props) { initialValues, }), /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [alertType, onCloseAddFlyout, triggersActionsUi] + [alertType, onCloseAddFlyout, services.triggersActionsUi] ); return <>{addFlyoutVisible && addAlertFlyout}; } diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx index 5671f0bfcf085a..9cb5a57b090f3e 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx @@ -6,7 +6,6 @@ */ import { shallow } from 'enzyme'; -import { Location } from 'history'; import React from 'react'; import { mockMoment } from '../../../../utils/testHelpers'; import { DetailView } from './index'; @@ -19,11 +18,7 @@ describe('DetailView', () => { it('should render empty state', () => { const wrapper = shallow( - + ); expect(wrapper.isEmptyRender()).toBe(true); }); @@ -46,11 +41,7 @@ describe('DetailView', () => { }; const wrapper = shallow( - + ).find('DiscoverErrorLink'); expect(wrapper.exists()).toBe(true); @@ -69,11 +60,7 @@ describe('DetailView', () => { transaction: undefined, }; const wrapper = shallow( - + ).find('Summary'); expect(wrapper.exists()).toBe(true); @@ -93,11 +80,7 @@ describe('DetailView', () => { } as any, }; const wrapper = shallow( - + ).find('EuiTabs'); expect(wrapper.exists()).toBe(true); @@ -117,11 +100,7 @@ describe('DetailView', () => { } as any, }; const wrapper = shallow( - + ).find('TabContent'); expect(wrapper.exists()).toBe(true); @@ -145,13 +124,7 @@ describe('DetailView', () => { } as any, }; expect(() => - shallow( - - ) + shallow() ).not.toThrowError(); }); }); diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx index cd893c17369889..da55f274bd77ce 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx @@ -16,7 +16,6 @@ import { EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Location } from 'history'; import { first } from 'lodash'; import React from 'react'; import { useHistory } from 'react-router-dom'; @@ -58,7 +57,6 @@ const TransactionLinkName = euiStyled.div` interface Props { errorGroup: APIReturnType<'GET /api/apm/services/{serviceName}/errors/{groupId}'>; urlParams: IUrlParams; - location: Location; } // TODO: Move query-string-based tabs into a re-usable component? @@ -70,7 +68,7 @@ function getCurrentTab( return selectedTab ? selectedTab : first(tabs) || {}; } -export function DetailView({ errorGroup, urlParams, location }: Props) { +export function DetailView({ errorGroup, urlParams }: Props) { const history = useHistory(); const { transaction, error, occurrencesCount } = errorGroup; diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index 5fcd2914f2225c..0f2180721afe3b 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -9,24 +9,19 @@ import { EuiBadge, EuiFlexGroup, EuiFlexItem, - EuiPage, - EuiPageBody, EuiPanel, EuiSpacer, EuiText, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; +import React from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useTrackPageview } from '../../../../../observability/public'; import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { SearchBar } from '../../shared/search_bar'; import { DetailView } from './DetailView'; import { ErrorDistribution } from './Distribution'; import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; @@ -68,44 +63,42 @@ function ErrorGroupHeader({ isUnhandled?: boolean; }) { return ( - <> - - - - -

- {i18n.translate('xpack.apm.errorGroupDetails.errorGroupTitle', { - defaultMessage: 'Error group {errorGroupId}', - values: { - errorGroupId: getShortGroupId(groupId), - }, - })} -

-
-
- {isUnhandled && ( - - - {i18n.translate('xpack.apm.errorGroupDetails.unhandledLabel', { - defaultMessage: 'Unhandled', - })} - - - )} -
-
- - + + + +

+ {i18n.translate('xpack.apm.errorGroupDetails.errorGroupTitle', { + defaultMessage: 'Error group {errorGroupId}', + values: { + errorGroupId: getShortGroupId(groupId), + }, + })} +

+
+
+ + {isUnhandled && ( + + + {i18n.translate('xpack.apm.errorGroupDetails.unhandledLabel', { + defaultMessage: 'Unhandled', + })} + + + )} +
); } -type ErrorGroupDetailsProps = RouteComponentProps<{ +interface ErrorGroupDetailsProps { groupId: string; serviceName: string; -}>; +} -export function ErrorGroupDetails({ location, match }: ErrorGroupDetailsProps) { - const { serviceName, groupId } = match.params; +export function ErrorGroupDetails({ + serviceName, + groupId, +}: ErrorGroupDetailsProps) { const { urlParams } = useUrlParams(); const { environment, kuery, start, end } = urlParams; const { data: errorGroupData } = useFetcher( @@ -154,66 +147,56 @@ export function ErrorGroupDetails({ location, match }: ErrorGroupDetailsProps) { return ( <> - - - - {showDetails && ( - - - {logMessage && ( - - - {logMessage} - - )} - - {excMessage || NOT_AVAILABLE_LABEL} + + + {showDetails && ( + + + {logMessage && ( + <> - {culprit || NOT_AVAILABLE_LABEL} - - - )} - {logMessage} + )} - /> - - - {showDetails && ( - + + {excMessage || NOT_AVAILABLE_LABEL} + + {culprit || NOT_AVAILABLE_LABEL} + + + )} + - + /> + + + {showDetails && ( + + )} ); } diff --git a/x-pack/plugins/apm/public/components/app/Home/Home.test.tsx b/x-pack/plugins/apm/public/components/app/Home/Home.test.tsx deleted file mode 100644 index ab3b76848c248f..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Home/Home.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { shallow } from 'enzyme'; -import React from 'react'; -import { Home } from '../Home'; -import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; - -describe('Home component', () => { - it('should render services', () => { - expect( - shallow( - - - - ) - ).toMatchSnapshot(); - }); - - it('should render traces', () => { - expect( - shallow( - - - - ) - ).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap deleted file mode 100644 index f13cce3fd9b40f..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ /dev/null @@ -1,189 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Home component should render services 1`] = ` - - - -`; - -exports[`Home component should render traces 1`] = ` - - - -`; diff --git a/x-pack/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx deleted file mode 100644 index 834c2d5c40bcec..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Home/index.tsx +++ /dev/null @@ -1,80 +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 { EuiTab, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { ComponentType } from 'react'; -import { $ElementType } from 'utility-types'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; -import { useServiceInventoryHref } from '../../shared/Links/apm/service_inventory_link'; -import { useTraceOverviewHref } from '../../shared/Links/apm/TraceOverviewLink'; -import { MainTabs } from '../../shared/main_tabs'; -import { ServiceMap } from '../ServiceMap'; -import { ServiceInventory } from '../service_inventory'; -import { TraceOverview } from '../trace_overview'; - -interface Tab { - key: string; - href: string; - text: string; - Component: ComponentType; -} - -interface Props { - tab: 'traces' | 'services' | 'service-map'; -} - -export function Home({ tab }: Props) { - const homeTabs: Tab[] = [ - { - key: 'services', - href: useServiceInventoryHref(), - text: i18n.translate('xpack.apm.home.servicesTabLabel', { - defaultMessage: 'Services', - }), - Component: ServiceInventory, - }, - { - key: 'traces', - href: useTraceOverviewHref(), - text: i18n.translate('xpack.apm.home.tracesTabLabel', { - defaultMessage: 'Traces', - }), - Component: TraceOverview, - }, - { - key: 'service-map', - href: useServiceMapHref(), - text: i18n.translate('xpack.apm.home.serviceMapTabLabel', { - defaultMessage: 'Service Map', - }), - Component: ServiceMap, - }, - ]; - const selectedTab = homeTabs.find( - (homeTab) => homeTab.key === tab - ) as $ElementType; - - return ( - <> - - -

APM

-
-
- - {homeTabs.map(({ href, key, text }) => ( - - {text} - - ))} - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx deleted file mode 100644 index 89b8db5f386dcd..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx +++ /dev/null @@ -1,369 +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 { i18n } from '@kbn/i18n'; -import React from 'react'; -import { Redirect, RouteComponentProps } from 'react-router-dom'; -import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context'; -import { getServiceNodeName } from '../../../../../common/service_nodes'; -import { APMRouteDefinition } from '../../../../application/routes'; -import { toQuery } from '../../../shared/Links/url_helpers'; -import { ErrorGroupDetails } from '../../ErrorGroupDetails'; -import { Home } from '../../Home'; -import { ServiceDetails } from '../../service_details'; -import { ServiceNodeMetrics } from '../../service_node_metrics'; -import { Settings } from '../../Settings'; -import { AgentConfigurations } from '../../Settings/AgentConfigurations'; -import { AnomalyDetection } from '../../Settings/anomaly_detection'; -import { ApmIndices } from '../../Settings/ApmIndices'; -import { CustomizeUI } from '../../Settings/CustomizeUI'; -import { TraceLink } from '../../TraceLink'; -import { TransactionDetails } from '../../transaction_details'; -import { - CreateAgentConfigurationRouteHandler, - EditAgentConfigurationRouteHandler, -} from './route_handlers/agent_configuration'; -import { enableServiceOverview } from '../../../../../common/ui_settings_keys'; -import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; - -/** - * Given a path, redirect to that location, preserving the search and maintaining - * backward-compatibilty with legacy (pre-7.9) hash-based URLs. - */ -export function renderAsRedirectTo(to: string) { - return ({ location }: RouteComponentProps<{}>) => { - let resolvedUrl: URL | undefined; - - // Redirect root URLs with a hash to support backward compatibility with URLs - // from before we switched to the non-hash platform history. - if (location.pathname === '' && location.hash.length > 0) { - // We just want the search and pathname so the host doesn't matter - resolvedUrl = new URL(location.hash.slice(1), 'http://localhost'); - to = resolvedUrl.pathname; - } - - return ( - - ); - }; -} - -// These component function definitions are used below with the `component` -// property of the route definitions. -// -// If you provide an inline function to the component prop, you would create a -// new component every render. This results in the existing component unmounting -// and the new component mounting instead of just updating the existing component. -function HomeServices() { - return ; -} - -function HomeServiceMap() { - return ; -} - -function HomeTraces() { - return ; -} - -function ServiceDetailsErrors( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsMetrics( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsNodes( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsOverview( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsServiceMap( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsTransactions( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function ServiceDetailsProfiling( - props: RouteComponentProps<{ serviceName: string }> -) { - return ; -} - -function SettingsAgentConfiguration(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function SettingsAnomalyDetection(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function SettingsApmIndices(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function SettingsCustomizeUI(props: RouteComponentProps<{}>) { - return ( - - - - ); -} - -function DefaultServicePageRouteHandler( - props: RouteComponentProps<{ serviceName: string }> -) { - const { uiSettings } = useApmPluginContext().core; - const { serviceName } = props.match.params; - if (uiSettings.get(enableServiceOverview)) { - return renderAsRedirectTo(`/services/${serviceName}/overview`)(props); - } - return renderAsRedirectTo(`/services/${serviceName}/transactions`)(props); -} - -/** - * The array of route definitions to be used when the application - * creates the routes. - */ -export const routes: APMRouteDefinition[] = [ - { - exact: true, - path: '/', - render: renderAsRedirectTo('/services'), - breadcrumb: 'APM', - }, - // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - { - exact: true, - path: '/services', - component: HomeServices, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.servicesTitle', { - defaultMessage: 'Services', - }), - }, - // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - { - exact: true, - path: '/traces', - component: HomeTraces, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.tracesTitle', { - defaultMessage: 'Traces', - }), - }, - { - exact: true, - path: '/settings', - render: renderAsRedirectTo('/settings/agent-configuration'), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.listSettingsTitle', { - defaultMessage: 'Settings', - }), - }, - { - exact: true, - path: '/settings/apm-indices', - component: SettingsApmIndices, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.indicesTitle', { - defaultMessage: 'Indices', - }), - }, - { - exact: true, - path: '/settings/agent-configuration', - component: SettingsAgentConfiguration, - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.agentConfigurationTitle', - { defaultMessage: 'Agent Configuration' } - ), - }, - { - exact: true, - path: '/settings/agent-configuration/create', - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.createAgentConfigurationTitle', - { defaultMessage: 'Create Agent Configuration' } - ), - component: CreateAgentConfigurationRouteHandler, - }, - { - exact: true, - path: '/settings/agent-configuration/edit', - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.editAgentConfigurationTitle', - { defaultMessage: 'Edit Agent Configuration' } - ), - component: EditAgentConfigurationRouteHandler, - }, - { - exact: true, - path: '/services/:serviceName', - breadcrumb: ({ match }) => match.params.serviceName, - component: DefaultServicePageRouteHandler, - } as APMRouteDefinition<{ serviceName: string }>, - { - exact: true, - path: '/services/:serviceName/overview', - breadcrumb: i18n.translate('xpack.apm.breadcrumb.overviewTitle', { - defaultMessage: 'Overview', - }), - component: withApmServiceContext(ServiceDetailsOverview), - } as APMRouteDefinition<{ serviceName: string }>, - // errors - { - exact: true, - path: '/services/:serviceName/errors/:groupId', - component: withApmServiceContext(ErrorGroupDetails), - breadcrumb: ({ match }) => match.params.groupId, - } as APMRouteDefinition<{ groupId: string; serviceName: string }>, - { - exact: true, - path: '/services/:serviceName/errors', - component: withApmServiceContext(ServiceDetailsErrors), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.errorsTitle', { - defaultMessage: 'Errors', - }), - }, - // transactions - { - exact: true, - path: '/services/:serviceName/transactions', - component: withApmServiceContext(ServiceDetailsTransactions), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.transactionsTitle', { - defaultMessage: 'Transactions', - }), - }, - // metrics - { - exact: true, - path: '/services/:serviceName/metrics', - component: withApmServiceContext(ServiceDetailsMetrics), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.metricsTitle', { - defaultMessage: 'Metrics', - }), - }, - // service nodes, only enabled for java agents for now - { - exact: true, - path: '/services/:serviceName/nodes', - component: withApmServiceContext(ServiceDetailsNodes), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.nodesTitle', { - defaultMessage: 'JVMs', - }), - }, - // node metrics - { - exact: true, - path: '/services/:serviceName/nodes/:serviceNodeName/metrics', - component: withApmServiceContext(ServiceNodeMetrics), - breadcrumb: ({ match }) => getServiceNodeName(match.params.serviceNodeName), - }, - { - exact: true, - path: '/services/:serviceName/transactions/view', - component: withApmServiceContext(TransactionDetails), - breadcrumb: ({ location }) => { - const query = toQuery(location.search); - return query.transactionName as string; - }, - }, - { - exact: true, - path: '/services/:serviceName/profiling', - component: withApmServiceContext(ServiceDetailsProfiling), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceProfilingTitle', { - defaultMessage: 'Profiling', - }), - }, - { - exact: true, - path: '/services/:serviceName/service-map', - component: withApmServiceContext(ServiceDetailsServiceMap), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { - defaultMessage: 'Service Map', - }), - }, - { - exact: true, - path: '/link-to/trace/:traceId', - component: TraceLink, - breadcrumb: null, - }, - // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts - { - exact: true, - path: '/service-map', - component: HomeServiceMap, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { - defaultMessage: 'Service Map', - }), - }, - { - exact: true, - path: '/settings/customize-ui', - component: SettingsCustomizeUI, - breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.customizeUI', { - defaultMessage: 'Customize UI', - }), - }, - { - exact: true, - path: '/settings/anomaly-detection', - component: SettingsAnomalyDetection, - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.anomalyDetection', - { - defaultMessage: 'Anomaly detection', - } - ), - }, -]; - -function withApmServiceContext(WrappedComponent: React.ComponentType) { - return (props: any) => { - return ( - - - - ); - }; -} diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx index 3225951fd6c70c..b781a6569cc359 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx @@ -42,12 +42,12 @@ export function AgentConfigurations() { return ( <> - -

+ +

{i18n.translate('xpack.apm.agentConfig.titleText', { defaultMessage: 'Agent central configuration', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx index 70672df85b649d..28cb4ebd51cddc 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx @@ -25,11 +25,11 @@ describe('ApmIndices', () => { ); expect(getByText('Indices')).toMatchInlineSnapshot(` -

Indices -

+ `); expect(spy).toHaveBeenCalledTimes(2); diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx index 9d2b4bba22afbe..44a3c4655417cf 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx @@ -176,12 +176,12 @@ export function ApmIndices() { return ( <> - -

+ +

{i18n.translate('xpack.apm.settings.apmIndices.title', { defaultMessage: 'Indices', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx index fabd70cec66475..c4b3c39248ffbf 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx @@ -13,12 +13,12 @@ import { CustomLinkOverview } from './CustomLink'; export function CustomizeUI() { return ( <> - -

+ +

{i18n.translate('xpack.apm.settings.customizeApp.title', { defaultMessage: 'Customize app', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx index 62b39664cf63da..38b9970f64d32c 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx @@ -66,12 +66,12 @@ export function AnomalyDetection() { return ( <> - -

+ +

{i18n.translate('xpack.apm.settings.anomalyDetection.titleText', { defaultMessage: 'Anomaly detection', })} -

+

diff --git a/x-pack/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/index.tsx index 36c36e3957e962..b4cba2afc2550d 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/index.tsx @@ -5,32 +5,19 @@ * 2.0. */ -import { - EuiButtonEmpty, - EuiPage, - EuiPageBody, - EuiPageSideBar, - EuiSideNav, - EuiSpacer, -} from '@elastic/eui'; +import { EuiPage, EuiPageBody, EuiPageSideBar, EuiSideNav } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ReactNode, useState } from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { HeaderMenuPortal } from '../../../../../observability/public'; -import { ActionMenu } from '../../../application/action_menu'; +import { useHistory } from 'react-router-dom'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { getAPMHref } from '../../shared/Links/apm/APMLink'; -import { HomeLink } from '../../shared/Links/apm/HomeLink'; -interface SettingsProps extends RouteComponentProps<{}> { - children: ReactNode; -} - -export function Settings({ children, location }: SettingsProps) { - const { appMountParameters, core } = useApmPluginContext(); +export function Settings({ children }: { children: ReactNode }) { + const { core } = useApmPluginContext(); + const history = useHistory(); const { basePath } = core.http; const canAccessML = !!core.application.capabilities.ml?.canAccessML; - const { search, pathname } = location; + const { search, pathname } = history.location; const [isSideNavOpenOnMobile, setisSideNavOpenOnMobile] = useState(false); @@ -43,86 +30,65 @@ export function Settings({ children, location }: SettingsProps) { } return ( - <> - - - - - - - - {i18n.translate('xpack.apm.settings.returnLinkLabel', { - defaultMessage: 'Return to inventory', - })} - - - - toggleOpenOnMobile()} - isOpenOnMobile={isSideNavOpenOnMobile} - items={[ - { - name: i18n.translate('xpack.apm.settings.pageTitle', { - defaultMessage: 'Settings', - }), - id: 0, - items: [ - { - name: i18n.translate('xpack.apm.settings.agentConfig', { - defaultMessage: 'Agent Configuration', - }), - id: '1', - href: getSettingsHref('/agent-configuration'), - isSelected: pathname.startsWith( - '/settings/agent-configuration' - ), - }, - ...(canAccessML - ? [ - { - name: i18n.translate( - 'xpack.apm.settings.anomalyDetection', - { - defaultMessage: 'Anomaly detection', - } - ), - id: '4', - href: getSettingsHref('/anomaly-detection'), - isSelected: - pathname === '/settings/anomaly-detection', - }, - ] - : []), - { - name: i18n.translate('xpack.apm.settings.customizeApp', { - defaultMessage: 'Customize app', - }), - id: '3', - href: getSettingsHref('/customize-ui'), - isSelected: pathname === '/settings/customize-ui', - }, - { - name: i18n.translate('xpack.apm.settings.indices', { - defaultMessage: 'Indices', - }), - id: '2', - href: getSettingsHref('/apm-indices'), - isSelected: pathname === '/settings/apm-indices', - }, - ], - }, - ]} - /> - - {children} - - + + + toggleOpenOnMobile()} + isOpenOnMobile={isSideNavOpenOnMobile} + items={[ + { + name: i18n.translate('xpack.apm.settings.pageTitle', { + defaultMessage: 'Settings', + }), + id: 0, + items: [ + { + name: i18n.translate('xpack.apm.settings.agentConfig', { + defaultMessage: 'Agent Configuration', + }), + id: '1', + href: getSettingsHref('/agent-configuration'), + isSelected: pathname.startsWith( + '/settings/agent-configuration' + ), + }, + ...(canAccessML + ? [ + { + name: i18n.translate( + 'xpack.apm.settings.anomalyDetection', + { + defaultMessage: 'Anomaly detection', + } + ), + id: '4', + href: getSettingsHref('/anomaly-detection'), + isSelected: pathname === '/settings/anomaly-detection', + }, + ] + : []), + { + name: i18n.translate('xpack.apm.settings.customizeApp', { + defaultMessage: 'Customize app', + }), + id: '3', + href: getSettingsHref('/customize-ui'), + isSelected: pathname === '/settings/customize-ui', + }, + { + name: i18n.translate('xpack.apm.settings.indices', { + defaultMessage: 'Indices', + }), + id: '2', + href: getSettingsHref('/apm-indices'), + isSelected: pathname === '/settings/apm-indices', + }, + ], + }, + ]} + /> + + {children} + ); } diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx index 6f7a8228db298e..95ec80b1a51bc2 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx @@ -7,7 +7,7 @@ import { EuiFlexGroup, - EuiPage, + EuiFlexItem, EuiPanel, EuiSpacer, EuiTitle, @@ -18,7 +18,6 @@ import { useTrackPageview } from '../../../../../observability/public'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useErrorGroupDistributionFetcher } from '../../../hooks/use_error_group_distribution_fetcher'; import { useFetcher } from '../../../hooks/use_fetcher'; -import { SearchBar } from '../../shared/search_bar'; import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; import { ErrorGroupList } from './List'; @@ -68,41 +67,41 @@ export function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { useTrackPageview({ app: 'apm', path: 'error_group_overview', delay: 15000 }); if (!errorDistributionData || !errorGroupListData) { - return ; + return null; } return ( - <> - - - - - - + + + + + + + + + +

+ {i18n.translate( + 'xpack.apm.serviceDetails.metrics.errorsList.title', + { defaultMessage: 'Errors' } + )} +

+
- - -

Errors

-
- - - -
-
-
- + + +
+
); } diff --git a/x-pack/plugins/apm/public/components/app/service_details/index.tsx b/x-pack/plugins/apm/public/components/app/service_details/index.tsx deleted file mode 100644 index 29bb1c04ab9456..00000000000000 --- a/x-pack/plugins/apm/public/components/app/service_details/index.tsx +++ /dev/null @@ -1,39 +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 { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; -import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { ServiceIcons } from './service_icons'; -import { ServiceDetailTabs } from './service_detail_tabs'; - -interface Props extends RouteComponentProps<{ serviceName: string }> { - tab: React.ComponentProps['tab']; -} - -export function ServiceDetails({ match, tab }: Props) { - const { serviceName } = match.params; - - return ( -
- - - - -

{serviceName}

-
-
- - - -
-
- -
- ); -} diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx deleted file mode 100644 index d360b186aba169..00000000000000 --- a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx +++ /dev/null @@ -1,202 +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 { EuiTab } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { ReactNode } from 'react'; -import { EuiBetaBadge } from '@elastic/eui'; -import { EuiFlexItem } from '@elastic/eui'; -import { EuiFlexGroup } from '@elastic/eui'; -import { isJavaAgentName, isRumAgentName } from '../../../../common/agent_name'; -import { enableServiceOverview } from '../../../../common/ui_settings_keys'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { useErrorOverviewHref } from '../../shared/Links/apm/ErrorOverviewLink'; -import { useMetricOverviewHref } from '../../shared/Links/apm/MetricOverviewLink'; -import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; -import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOverviewLink'; -import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; -import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; -import { useServiceProfilingHref } from '../../shared/Links/apm/service_profiling_link'; -import { MainTabs } from '../../shared/main_tabs'; -import { ErrorGroupOverview } from '../error_group_overview'; -import { ServiceMap } from '../ServiceMap'; -import { ServiceNodeOverview } from '../service_node_overview'; -import { ServiceMetrics } from '../service_metrics'; -import { ServiceOverview } from '../service_overview'; -import { TransactionOverview } from '../transaction_overview'; -import { ServiceProfiling } from '../service_profiling'; -import { Correlations } from '../correlations'; - -interface Tab { - key: string; - href: string; - text: ReactNode; - hidden?: boolean; - render: () => ReactNode; -} - -interface Props { - serviceName: string; - tab: - | 'errors' - | 'metrics' - | 'nodes' - | 'overview' - | 'service-map' - | 'profiling' - | 'transactions'; -} - -export function ServiceDetailTabs({ serviceName, tab }: Props) { - const { agentName, transactionType } = useApmServiceContext(); - const { - core: { uiSettings }, - config, - } = useApmPluginContext(); - const { - urlParams: { latencyAggregationType }, - } = useUrlParams(); - - const overviewTab = { - key: 'overview', - href: useServiceOverviewHref({ serviceName, transactionType }), - text: i18n.translate('xpack.apm.serviceDetails.overviewTabLabel', { - defaultMessage: 'Overview', - }), - render: () => ( - - ), - }; - - const transactionsTab = { - key: 'transactions', - href: useTransactionsOverviewHref({ - serviceName, - latencyAggregationType, - transactionType, - }), - text: i18n.translate('xpack.apm.serviceDetails.transactionsTabLabel', { - defaultMessage: 'Transactions', - }), - render: () => , - }; - - const errorsTab = { - key: 'errors', - href: useErrorOverviewHref(serviceName), - text: i18n.translate('xpack.apm.serviceDetails.errorsTabLabel', { - defaultMessage: 'Errors', - }), - render: () => { - return ; - }, - }; - - const serviceMapTab = { - key: 'service-map', - href: useServiceMapHref(serviceName), - text: i18n.translate('xpack.apm.home.serviceMapTabLabel', { - defaultMessage: 'Service Map', - }), - render: () => , - }; - - const nodesListTab = { - key: 'nodes', - href: useServiceNodeOverviewHref(serviceName), - text: i18n.translate('xpack.apm.serviceDetails.nodesTabLabel', { - defaultMessage: 'JVMs', - }), - render: () => , - }; - - const metricsTab = { - key: 'metrics', - href: useMetricOverviewHref(serviceName), - text: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', { - defaultMessage: 'Metrics', - }), - render: () => - agentName ? ( - - ) : null, - }; - - const profilingTab = { - key: 'profiling', - href: useServiceProfilingHref({ serviceName }), - hidden: !config.profilingEnabled, - text: ( - - - {i18n.translate('xpack.apm.serviceDetails.profilingTabLabel', { - defaultMessage: 'Profiling', - })} - - - - - - ), - render: () => , - }; - - const tabs: Tab[] = [transactionsTab, errorsTab]; - - if (uiSettings.get(enableServiceOverview)) { - tabs.unshift(overviewTab); - } - - if (isJavaAgentName(agentName)) { - tabs.push(nodesListTab); - } else if (agentName && !isRumAgentName(agentName)) { - tabs.push(metricsTab); - } - - tabs.push(serviceMapTab, profilingTab); - - const selectedTab = tabs.find((serviceTab) => serviceTab.key === tab); - - return ( - <> - - {tabs - .filter((t) => !t.hidden) - .map(({ href, key, text }) => ( - - {text} - - ))} -
- -
-
- {selectedTab ? selectedTab.render() : null} - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index 9c4728488d96a1..78f02c5a667016 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -5,13 +5,7 @@ * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiPage, - EuiPanel, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect } from 'react'; import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; @@ -126,28 +120,26 @@ export function ServiceInventory() { return ( <> - - - {displayMlCallout ? ( - - setUserHasDismissedCallout(true)} /> - - ) : null} + + {displayMlCallout && ( - - - } - /> - + setUserHasDismissedCallout(true)} /> - - + )} + + + + } + /> + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/Controls.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Controls.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Controls.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx b/x-pack/plugins/apm/public/components/app/service_map/Controls.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Controls.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/service_map/Cytoscape.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Cytoscape.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx b/x-pack/plugins/apm/public/components/app/service_map/EmptyBanner.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx rename to x-pack/plugins/apm/public/components/app/service_map/EmptyBanner.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/AnomalyDetection.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/AnomalyDetection.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Buttons.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Contents.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Contents.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Info.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Info.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx similarity index 97% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx index ac1846155569af..fe3922060533a9 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx @@ -13,11 +13,11 @@ import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; import { createCallApmApi } from '../../../../services/rest/createCallApmApi'; import { CytoscapeContext } from '../Cytoscape'; -import { Popover } from './'; +import { Popover } from '.'; import exampleGroupedConnectionsData from '../__stories__/example_grouped_connections.json'; export default { - title: 'app/ServiceMap/Popover', + title: 'app/service_map/Popover', component: Popover, decorators: [ (Story: ComponentType) => { diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsFetcher.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsFetcher.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsList.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/ServiceStatsList.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx similarity index 96% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx index a8f004a7295d90..83f0a3ea7e4b9e 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/service_stats_list.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx @@ -10,7 +10,7 @@ import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_rea import { ServiceStatsList } from './ServiceStatsList'; export default { - title: 'app/ServiceMap/Popover/ServiceStatsList', + title: 'app/service_map/Popover/ServiceStatsList', component: ServiceStatsList, decorators: [ (Story: ComponentType) => ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx similarity index 99% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx index 37644c084815e9..c3f3c09e10e4f8 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx @@ -14,7 +14,7 @@ import { iconForNode } from '../icons'; import { Centerer } from './centerer'; export default { - title: 'app/ServiceMap/Cytoscape', + title: 'app/service_map/Cytoscape', component: Cytoscape, decorators: [ (Story: ComponentType) => ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/centerer.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/centerer.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/centerer.tsx rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/centerer.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx similarity index 98% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx index 41eecb9181a2c5..84351d5716edb2 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/cytoscape_example_data.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx @@ -25,7 +25,7 @@ import exampleResponseOpbeansBeats from './example_response_opbeans_beats.json'; import exampleResponseTodo from './example_response_todo.json'; import { generateServiceMapElements } from './generate_service_map_elements'; -const STORYBOOK_PATH = 'app/ServiceMap/Cytoscape/Example data'; +const STORYBOOK_PATH = 'app/service_map/Cytoscape/Example data'; const SESSION_STORAGE_KEY = `${STORYBOOK_PATH}/pre-loaded map`; function getSessionJson() { @@ -40,7 +40,7 @@ function getHeight() { } export default { - title: 'app/ServiceMap/Cytoscape/Example data', + title: 'app/service_map/Cytoscape/Example data', component: Cytoscape, decorators: [ (Story: ComponentType) => ( diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_grouped_connections.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_grouped_connections.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_grouped_connections.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_grouped_connections.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_hipster_store.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_hipster_store.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_hipster_store.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_hipster_store.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_opbeans_beats.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_opbeans_beats.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_opbeans_beats.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_opbeans_beats.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_todo.json b/x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_todo.json similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/example_response_todo.json rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/example_response_todo.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts b/x-pack/plugins/apm/public/components/app/service_map/__stories__/generate_service_map_elements.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts rename to x-pack/plugins/apm/public/components/app/service_map/__stories__/generate_service_map_elements.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts b/x-pack/plugins/apm/public/components/app/service_map/cytoscape_options.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape_options.ts rename to x-pack/plugins/apm/public/components/app/service_map/cytoscape_options.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/empty_banner.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/empty_banner.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/empty_banner.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/empty_banner.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/empty_prompt.tsx b/x-pack/plugins/apm/public/components/app/service_map/empty_prompt.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/empty_prompt.tsx rename to x-pack/plugins/apm/public/components/app/service_map/empty_prompt.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/plugins/apm/public/components/app/service_map/icons.ts similarity index 100% rename from x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts rename to x-pack/plugins/apm/public/components/app/service_map/icons.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx similarity index 99% rename from x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx rename to x-pack/plugins/apm/public/components/app/service_map/index.test.tsx index e8384de1d15bae..f68d8e46f66e3a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.test.tsx @@ -15,7 +15,7 @@ import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/ import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { LicenseContext } from '../../../context/license/license_context'; import * as useFetcherModule from '../../../hooks/use_fetcher'; -import { ServiceMap } from './'; +import { ServiceMap } from '.'; import { UrlParamsProvider } from '../../../context/url_params_context/url_params_context'; import { Router } from 'react-router-dom'; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/service_map/index.tsx similarity index 87% rename from x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx rename to x-pack/plugins/apm/public/components/app/service_map/index.tsx index b338d1e4ab03dc..714228d58f9620 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/index.tsx @@ -7,7 +7,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import React, { PropsWithChildren, ReactNode } from 'react'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { isActivePlatinumLicense } from '../../../../common/license_check'; import { useTrackPageview } from '../../../../../observability/public'; import { @@ -18,7 +17,6 @@ import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useLicenseContext } from '../../../context/license/use_license_context'; import { useTheme } from '../../../hooks/use_theme'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { DatePicker } from '../../shared/DatePicker'; import { LicensePrompt } from '../../shared/license_prompt'; import { Controls } from './Controls'; import { Cytoscape } from './Cytoscape'; @@ -28,31 +26,16 @@ import { EmptyPrompt } from './empty_prompt'; import { Popover } from './Popover'; import { TimeoutPrompt } from './timeout_prompt'; import { useRefDimensions } from './useRefDimensions'; +import { SearchBar } from '../../shared/search_bar'; interface ServiceMapProps { serviceName?: string; } -const ServiceMapDatePickerFlexGroup = euiStyled(EuiFlexGroup)` - padding: ${({ theme }) => theme.eui.euiSizeM}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; - margin: 0; -`; - -function DatePickerSection() { - return ( - - - - - - ); -} - function PromptContainer({ children }: { children: ReactNode }) { return ( <> - + - + +
- - - - - - {data.charts.map((chart) => ( - - - - - - ))} - - - - - - + + + {data.charts.map((chart) => ( + + + + + + ))} + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx index d9cd003042a45a..8711366fdd1859 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.test.tsx @@ -9,20 +9,17 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ServiceNodeMetrics } from '.'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; -import { RouteComponentProps } from 'react-router-dom'; describe('ServiceNodeMetrics', () => { describe('render', () => { it('renders', () => { - const props = ({} as unknown) as RouteComponentProps<{ - serviceName: string; - serviceNodeName: string; - }>; - expect(() => shallow( - + ) ).not.toThrowError(); diff --git a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx index 186c148fa69187..20b78b90e03781 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_metrics/index.tsx @@ -10,17 +10,14 @@ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, - EuiPage, EuiPanel, EuiSpacer, EuiStat, - EuiTitle, EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; @@ -29,10 +26,8 @@ import { useServiceMetricChartsFetcher } from '../../../hooks/use_service_metric import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { px, truncate, unit } from '../../../style/variables'; -import { ApmHeader } from '../../shared/ApmHeader'; import { MetricsChart } from '../../shared/charts/metrics_chart'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; -import { SearchBar } from '../../shared/search_bar'; const INITIAL_DATA = { host: '', @@ -51,16 +46,18 @@ const MetadataFlexGroup = euiStyled(EuiFlexGroup)` `${theme.eui.paddingSizes.m} 0 0 ${theme.eui.paddingSizes.m}`}; `; -type ServiceNodeMetricsProps = RouteComponentProps<{ +interface ServiceNodeMetricsProps { serviceName: string; serviceNodeName: string; -}>; +} -export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { +export function ServiceNodeMetrics({ + serviceName, + serviceNodeName, +}: ServiceNodeMetricsProps) { const { urlParams: { kuery, start, end }, } = useUrlParams(); - const { serviceName, serviceNodeName } = match.params; const { agentName } = useApmServiceContext(); const { data } = useServiceMetricChartsFetcher({ serviceNodeName }); @@ -89,15 +86,6 @@ export function ServiceNodeMetrics({ match }: ServiceNodeMetricsProps) { return ( <> - - - - -

{serviceName}

-
-
-
-
{isAggregatedData ? ( )} - - - {agentName && ( - - - {data.charts.map((chart) => ( - - - - - - ))} - - - - )} - + + {agentName && ( + + + {data.charts.map((chart) => ( + + + + + + ))} + + + + )} ); } diff --git a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx index 3d284de621ea38..69e5ea5a78ea12 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiFlexGroup, EuiPage, EuiPanel, EuiToolTip } from '@elastic/eui'; +import { EuiPanel, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; @@ -22,7 +22,6 @@ import { useFetcher } from '../../../hooks/use_fetcher'; import { px, truncate, unit } from '../../../style/variables'; import { ServiceNodeMetricOverviewLink } from '../../shared/Links/apm/ServiceNodeMetricOverviewLink'; import { ITableColumn, ManagedTable } from '../../shared/ManagedTable'; -import { SearchBar } from '../../shared/search_bar'; const INITIAL_PAGE_SIZE = 25; const INITIAL_SORT_FIELD = 'cpu'; @@ -143,28 +142,18 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { ]; return ( - <> - - - - - - - - - + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index cd1ced1830123f..f7046d9e401381 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -5,17 +5,17 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; import { useBreakPoints } from '../../../hooks/use_break_points'; import { LatencyChart } from '../../shared/charts/latency_chart'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; -import { SearchBar } from '../../shared/search_bar'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; import { ServiceOverviewInstancesChartAndTable } from './service_overview_instances_chart_and_table'; @@ -29,14 +29,12 @@ import { ServiceOverviewTransactionsTable } from './service_overview_transaction export const chartHeight = 288; interface ServiceOverviewProps { - agentName?: string; serviceName: string; } -export function ServiceOverview({ - agentName, - serviceName, -}: ServiceOverviewProps) { +export function ServiceOverview({ serviceName }: ServiceOverviewProps) { + const { agentName } = useApmServiceContext(); + useTrackPageview({ app: 'apm', path: 'service_overview' }); useTrackPageview({ app: 'apm', path: 'service_overview', delay: 15000 }); @@ -49,89 +47,84 @@ export function ServiceOverview({ return ( - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + {!isRumAgent && ( - + + )} + + + + + + + + + + + + + {!isRumAgent && ( - - - + )} + + + {!isRumAgent && ( - {!isRumAgent && ( - - - - )} - - - - - + - - - - - - {!isRumAgent && ( - - - - - - )} - - - {!isRumAgent && ( - - - - - - )} - - + )} + ); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx index 46747e18c44afd..a92efff1039104 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx @@ -234,6 +234,7 @@ export function getColumns({ anchorPosition="leftCenter" button={ diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx index ba1da7e6dd6eb9..5c2bbd9e20c59e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/intance_details.tsx @@ -32,10 +32,7 @@ import { pct } from '../../../../style/variables'; import { getAgentIcon } from '../../../shared/AgentIcon/get_agent_icon'; import { KeyValueFilterList } from '../../../shared/key_value_filter_list'; import { pushNewItemToKueryBar } from '../../../shared/KueryBar/utils'; -import { - getCloudIcon, - getContainerIcon, -} from '../../service_details/service_icons'; +import { getCloudIcon, getContainerIcon } from '../../../shared/service_icons'; import { useInstanceDetailsFetcher } from './use_instance_details_fetcher'; type ServiceInstanceDetails = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx index 94391b5b2fb069..c6e1f575298c68 100644 --- a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx @@ -4,14 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiPage, - EuiPanel, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; import React, { useEffect, useState } from 'react'; import { getValueTypeConfig, @@ -20,7 +13,6 @@ import { import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useFetcher } from '../../../hooks/use_fetcher'; import { APIReturnType } from '../../../services/rest/createCallApmApi'; -import { SearchBar } from '../../shared/search_bar'; import { ServiceProfilingFlamegraph } from './service_profiling_flamegraph'; import { ServiceProfilingTimeline } from './service_profiling_timeline'; @@ -90,54 +82,38 @@ export function ServiceProfiling({ return ( <> - - - - - -

- {i18n.translate('xpack.apm.profilingOverviewTitle', { - defaultMessage: 'Profiling', - })} -

-
+ + + + { + setValueType(type); + }} + selectedValueType={valueType} + /> + {valueType ? ( + + +

{getValueTypeConfig(valueType).label}

+
+
+ ) : null} - - - - { - setValueType(type); - }} - selectedValueType={valueType} - /> - - {valueType ? ( - - -

{getValueTypeConfig(valueType).label}

-
-
- ) : null} - - - -
-
+
-
+ ); } diff --git a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx index 364266d277482a..0938456193dc0d 100644 --- a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiPage, EuiPanel } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -50,16 +50,13 @@ export function TraceOverview() { return ( <> - - - - - - - + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx index 7f8ffb62d9e728..ae58e6f60cf09c 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/TransactionTabs.tsx @@ -7,7 +7,6 @@ import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Location } from 'history'; import React from 'react'; import { useHistory } from 'react-router-dom'; import { LogStream } from '../../../../../../infra/public'; @@ -19,7 +18,6 @@ import { WaterfallContainer } from './WaterfallContainer'; import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers'; interface Props { - location: Location; transaction: Transaction; urlParams: IUrlParams; waterfall: IWaterfall; @@ -27,7 +25,6 @@ interface Props { } export function TransactionTabs({ - location, transaction, urlParams, waterfall, @@ -47,9 +44,9 @@ export function TransactionTabs({ { history.replace({ - ...location, + ...history.location, search: fromQuery({ - ...toQuery(location.search), + ...toQuery(history.location.search), detailTab: key, }), }); @@ -66,7 +63,6 @@ export function TransactionTabs({ ; urlParams: IUrlParams; waterfall: IWaterfall; exceedsMax: boolean; }) { return ( void; + toggleFlyout: ({ history }: { history: History }) => void; } export function WaterfallFlyout({ waterfallItemId, waterfall, - location, toggleFlyout, }: Props) { const history = useHistory(); @@ -52,14 +44,14 @@ export function WaterfallFlyout({ totalDuration={waterfall.duration} span={currentItem.doc} parentTransaction={parentTransaction} - onClose={() => toggleFlyout({ history, location })} + onClose={() => toggleFlyout({ history })} /> ); case 'transaction': return ( toggleFlyout({ history, location })} + onClose={() => toggleFlyout({ history })} rootTransactionDuration={ waterfall.rootTransaction?.transaction.duration.us } diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx index baced34ad3e56f..b0721791081fac 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/accordion_waterfall.tsx @@ -6,7 +6,6 @@ */ import { EuiAccordion, EuiAccordionProps } from '@elastic/eui'; -import { Location } from 'history'; import { isEmpty } from 'lodash'; import React, { useState } from 'react'; import { euiStyled } from '../../../../../../../../../../src/plugins/kibana_react/common'; @@ -23,7 +22,6 @@ interface AccordionWaterfallProps { level: number; duration: IWaterfall['duration']; waterfallItemId?: string; - location: Location; errorsPerTransaction: IWaterfall['errorsPerTransaction']; childrenByParentId: Record; onToggleEntryTransaction?: () => void; @@ -100,7 +98,6 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { duration, childrenByParentId, waterfallItemId, - location, errorsPerTransaction, timelineMargins, onClickWaterfallItem, @@ -160,7 +157,6 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { item={child} level={nextLevel} waterfallItemId={waterfallItemId} - location={location} errorsPerTransaction={errorsPerTransaction} duration={duration} childrenByParentId={childrenByParentId} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx index 4be595ac16c6cc..d7613699221b48 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx @@ -7,7 +7,7 @@ import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { History, Location } from 'history'; +import { History } from 'history'; import React, { useState } from 'react'; import { useHistory } from 'react-router-dom'; import { euiStyled } from '../../../../../../../../../../src/plugins/kibana_react/common'; @@ -40,14 +40,12 @@ const TIMELINE_MARGINS = { const toggleFlyout = ({ history, item, - location, }: { history: History; item?: IWaterfallItem; - location: Location; }) => { history.replace({ - ...location, + ...history.location, search: fromQuery({ ...toQuery(location.search), flyoutDetailTab: undefined, @@ -63,15 +61,9 @@ const WaterfallItemsContainer = euiStyled.div` interface Props { waterfallItemId?: string; waterfall: IWaterfall; - location: Location; exceedsMax: boolean; } -export function Waterfall({ - waterfall, - exceedsMax, - waterfallItemId, - location, -}: Props) { +export function Waterfall({ waterfall, exceedsMax, waterfallItemId }: Props) { const history = useHistory(); const [isAccordionOpen, setIsAccordionOpen] = useState(true); const itemContainerHeight = 58; // TODO: This is a nasty way to calculate the height of the svg element. A better approach should be found @@ -97,13 +89,12 @@ export function Waterfall({ item={entryWaterfallTransaction} level={0} waterfallItemId={waterfallItemId} - location={location} errorsPerTransaction={waterfall.errorsPerTransaction} duration={duration} childrenByParentId={childrenByParentId} timelineMargins={TIMELINE_MARGINS} onClickWaterfallItem={(item: IWaterfallItem) => - toggleFlyout({ history, item, location }) + toggleFlyout({ history, item }) } onToggleEntryTransaction={() => setIsAccordionOpen((isOpen) => !isOpen)} /> @@ -148,7 +139,6 @@ export function Waterfall({ diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx index 57743590ea5666..5ea2fca2dfa321 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx @@ -15,7 +15,6 @@ import { WaterfallContainer } from './index'; import { getWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers'; import { inferredSpans, - location, simpleTrace, traceChildStartBeforeParent, traceWithErrors, @@ -45,7 +44,6 @@ export function Example() { ); return ( ; - -export function TransactionDetails({ - location, - match, -}: TransactionDetailsProps) { +export function TransactionDetails() { const { urlParams } = useUrlParams(); const history = useHistory(); const { @@ -90,48 +76,43 @@ export function TransactionDetails({ return ( <> - - -

{transactionName}

-
-
- - - - - - - - - - - { - if (!isEmpty(bucket.samples)) { - selectSampleFromBucketClick(bucket.samples[0]); - } - }} - /> - - - - - - - - - + +

{transactionName}

+
+ + + + + + + + + + + { + if (!isEmpty(bucket.samples)) { + selectSampleFromBucketClick(bucket.samples[0]); + } + }} + /> + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx index 9e2743d7b59862..38066b4ecd3f79 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/index.tsx @@ -8,8 +8,6 @@ import { EuiCallOut, EuiCode, - EuiFlexGroup, - EuiPage, EuiPanel, EuiSpacer, EuiTitle, @@ -26,7 +24,6 @@ import { useUrlParams } from '../../../context/url_params_context/use_url_params import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; -import { SearchBar } from '../../shared/search_bar'; import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; import { useTransactionListFetcher } from './use_transaction_list'; @@ -80,62 +77,55 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { return ( <> - - - - - - - -

Transactions

-
- - {!transactionListData.isAggregationAccurate && ( - -

- - xpack.apm.ui.transactionGroupBucketSize - - ), - }} - /> - - - {i18n.translate( - 'xpack.apm.transactionCardinalityWarning.docsLink', - { defaultMessage: 'Learn more in the docs' } - )} - -

-
+ + + + +

Transactions

+
+ + {!transactionListData.isAggregationAccurate && ( + - -
-
-
+ color="danger" + iconType="alert" + > +

+ xpack.apm.ui.transactionGroupBucketSize + ), + }} + /> + + + {i18n.translate( + 'xpack.apm.transactionCardinalityWarning.docsLink', + { defaultMessage: 'Learn more in the docs' } + )} + +

+
+ )} + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index e4fbd075660605..9c4c2aa11a8582 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { fireEvent, getByText, queryByLabelText } from '@testing-library/react'; +import { queryByLabelText } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { CoreStart } from 'kibana/public'; import React from 'react'; @@ -107,46 +107,6 @@ describe('TransactionOverview', () => { const FILTER_BY_TYPE_LABEL = 'Transaction type'; - describe('when transactionType is selected and multiple transaction types are given', () => { - it('renders a radio group with transaction types', () => { - const { container } = setup({ - serviceTransactionTypes: ['firstType', 'secondType'], - urlParams: { - transactionType: 'secondType', - }, - }); - - expect(getByText(container, 'firstType')).toBeInTheDocument(); - expect(getByText(container, 'secondType')).toBeInTheDocument(); - - expect(getByText(container, 'firstType')).not.toBeNull(); - }); - - it('should update the URL when a transaction type is selected', () => { - const { container } = setup({ - serviceTransactionTypes: ['firstType', 'secondType'], - urlParams: { - transactionType: 'secondType', - }, - }); - - expect(history.location.search).toEqual( - '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now' - ); - expect(getByText(container, 'firstType')).toBeInTheDocument(); - expect(getByText(container, 'secondType')).toBeInTheDocument(); - - fireEvent.change(getByText(container, 'firstType').parentElement!, { - target: { value: 'firstType' }, - }); - - expect(history.push).toHaveBeenCalled(); - expect(history.location.search).toEqual( - '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now' - ); - }); - }); - describe('when a transaction type is selected, and there are no other transaction types', () => { it('does not render a radio group with transaction types', () => { const { container } = setup({ diff --git a/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx new file mode 100644 index 00000000000000..af62f4f235af7f --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/apm_route_config.tsx @@ -0,0 +1,478 @@ +/* + * 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 React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { getServiceNodeName } from '../../../common/service_nodes'; +import { APMRouteDefinition } from '../../application/routes'; +import { toQuery } from '../shared/Links/url_helpers'; +import { ErrorGroupDetails } from '../app/ErrorGroupDetails'; +import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { ServiceNodeMetrics } from '../app/service_node_metrics'; +import { Settings } from '../app/Settings'; +import { AgentConfigurations } from '../app/Settings/AgentConfigurations'; +import { AnomalyDetection } from '../app/Settings/anomaly_detection'; +import { ApmIndices } from '../app/Settings/ApmIndices'; +import { CustomizeUI } from '../app/Settings/CustomizeUI'; +import { TraceLink } from '../app/TraceLink'; +import { TransactionDetails } from '../app/transaction_details'; +import { + CreateAgentConfigurationRouteHandler, + EditAgentConfigurationRouteHandler, +} from './route_handlers/agent_configuration'; +import { enableServiceOverview } from '../../../common/ui_settings_keys'; +import { redirectTo } from './redirect_to'; +import { ApmMainTemplate } from './templates/apm_main_template'; +import { ApmServiceTemplate } from './templates/apm_service_template'; +import { ServiceProfiling } from '../app/service_profiling'; +import { ErrorGroupOverview } from '../app/error_group_overview'; +import { ServiceMap } from '../app/service_map'; +import { ServiceNodeOverview } from '../app/service_node_overview'; +import { ServiceMetrics } from '../app/service_metrics'; +import { ServiceOverview } from '../app/service_overview'; +import { TransactionOverview } from '../app/transaction_overview'; +import { ServiceInventory } from '../app/service_inventory'; +import { TraceOverview } from '../app/trace_overview'; + +// These component function definitions are used below with the `component` +// property of the route definitions. +// +// If you provide an inline function to the component prop, you would create a +// new component every render. This results in the existing component unmounting +// and the new component mounting instead of just updating the existing component. + +const ServiceInventoryTitle = i18n.translate( + 'xpack.apm.views.serviceInventory.title', + { defaultMessage: 'Services' } +); + +function ServiceInventoryView() { + return ( + + + + ); +} + +const TraceOverviewTitle = i18n.translate( + 'xpack.apm.views.traceOverview.title', + { + defaultMessage: 'Traces', + } +); + +function TraceOverviewView() { + return ( + + + + ); +} + +const ServiceMapTitle = i18n.translate('xpack.apm.views.serviceMap.title', { + defaultMessage: 'Service Map', +}); + +function ServiceMapView() { + return ( + + + + ); +} + +function ServiceDetailsErrorsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ErrorGroupDetailsRouteView( + props: RouteComponentProps<{ serviceName: string; groupId: string }> +) { + const { serviceName, groupId } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsMetricsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsNodesRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsOverviewRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsServiceMapRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsTransactionsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceDetailsProfilingRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function ServiceNodeMetricsRouteView( + props: RouteComponentProps<{ + serviceName: string; + serviceNodeName: string; + }> +) { + const { serviceName, serviceNodeName } = props.match.params; + return ( + + + + ); +} + +function TransactionDetailsRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { serviceName } = props.match.params; + return ( + + + + ); +} + +function SettingsAgentConfigurationRouteView() { + return ( + + + + + + ); +} + +function SettingsAnomalyDetectionRouteView() { + return ( + + + + + + ); +} + +function SettingsApmIndicesRouteView() { + return ( + + + + + + ); +} + +function SettingsCustomizeUI() { + return ( + + + + + + ); +} + +const SettingsApmIndicesTitle = i18n.translate( + 'xpack.apm.views.settings.indices.title', + { defaultMessage: 'Indices' } +); + +const SettingsAgentConfigurationTitle = i18n.translate( + 'xpack.apm.views.settings.agentConfiguration.title', + { defaultMessage: 'Agent Configuration' } +); +const CreateAgentConfigurationTitle = i18n.translate( + 'xpack.apm.views.settings.createAgentConfiguration.title', + { defaultMessage: 'Create Agent Configuration' } +); +const EditAgentConfigurationTitle = i18n.translate( + 'xpack.apm.views.settings.editAgentConfiguration.title', + { defaultMessage: 'Edit Agent Configuration' } +); +const SettingsCustomizeUITitle = i18n.translate( + 'xpack.apm.views.settings.customizeUI.title', + { defaultMessage: 'Customize app' } +); +const SettingsAnomalyDetectionTitle = i18n.translate( + 'xpack.apm.views.settings.anomalyDetection.title', + { defaultMessage: 'Anomaly detection' } +); +const SettingsTitle = i18n.translate('xpack.apm.views.listSettings.title', { + defaultMessage: 'Settings', +}); + +/** + * The array of route definitions to be used when the application + * creates the routes. + */ +export const apmRouteConfig: APMRouteDefinition[] = [ + /* + * Home routes + */ + { + exact: true, + path: '/', + render: redirectTo('/services'), + breadcrumb: 'APM', + }, + { + exact: true, + path: '/services', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts + component: ServiceInventoryView, + breadcrumb: ServiceInventoryTitle, + }, + { + exact: true, + path: '/traces', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts + component: TraceOverviewView, + breadcrumb: TraceOverviewTitle, + }, + { + exact: true, + path: '/service-map', // !! Need to be kept in sync with the deepLinks in x-pack/plugins/apm/public/plugin.ts + component: ServiceMapView, + breadcrumb: ServiceMapTitle, + }, + + /* + * Settings routes + */ + { + exact: true, + path: '/settings', + render: redirectTo('/settings/agent-configuration'), + breadcrumb: SettingsTitle, + }, + { + exact: true, + path: '/settings/agent-configuration', + component: SettingsAgentConfigurationRouteView, + breadcrumb: SettingsAgentConfigurationTitle, + }, + { + exact: true, + path: '/settings/agent-configuration/create', + component: CreateAgentConfigurationRouteHandler, + breadcrumb: CreateAgentConfigurationTitle, + }, + { + exact: true, + path: '/settings/agent-configuration/edit', + breadcrumb: EditAgentConfigurationTitle, + component: EditAgentConfigurationRouteHandler, + }, + { + exact: true, + path: '/settings/apm-indices', + component: SettingsApmIndicesRouteView, + breadcrumb: SettingsApmIndicesTitle, + }, + { + exact: true, + path: '/settings/customize-ui', + component: SettingsCustomizeUI, + breadcrumb: SettingsCustomizeUITitle, + }, + { + exact: true, + path: '/settings/anomaly-detection', + component: SettingsAnomalyDetectionRouteView, + breadcrumb: SettingsAnomalyDetectionTitle, + }, + + /* + * Services routes (with APM Service context) + */ + { + exact: true, + path: '/services/:serviceName', + breadcrumb: ({ match }) => match.params.serviceName, + component: RedirectToDefaultServiceRouteView, + }, + { + exact: true, + path: '/services/:serviceName/overview', + breadcrumb: i18n.translate('xpack.apm.views.overview.title', { + defaultMessage: 'Overview', + }), + component: ServiceDetailsOverviewRouteView, + }, + { + exact: true, + path: '/services/:serviceName/transactions', + component: ServiceDetailsTransactionsRouteView, + breadcrumb: i18n.translate('xpack.apm.views.transactions.title', { + defaultMessage: 'Transactions', + }), + }, + { + exact: true, + path: '/services/:serviceName/errors/:groupId', + component: ErrorGroupDetailsRouteView, + breadcrumb: ({ match }) => match.params.groupId, + }, + { + exact: true, + path: '/services/:serviceName/errors', + component: ServiceDetailsErrorsRouteView, + breadcrumb: i18n.translate('xpack.apm.views.errors.title', { + defaultMessage: 'Errors', + }), + }, + { + exact: true, + path: '/services/:serviceName/metrics', + component: ServiceDetailsMetricsRouteView, + breadcrumb: i18n.translate('xpack.apm.views.metrics.title', { + defaultMessage: 'Metrics', + }), + }, + // service nodes, only enabled for java agents for now + { + exact: true, + path: '/services/:serviceName/nodes', + component: ServiceDetailsNodesRouteView, + breadcrumb: i18n.translate('xpack.apm.views.nodes.title', { + defaultMessage: 'JVMs', + }), + }, + // node metrics + { + exact: true, + path: '/services/:serviceName/nodes/:serviceNodeName/metrics', + component: ServiceNodeMetricsRouteView, + breadcrumb: ({ match }) => getServiceNodeName(match.params.serviceNodeName), + }, + { + exact: true, + path: '/services/:serviceName/transactions/view', + component: TransactionDetailsRouteView, + breadcrumb: ({ location }) => { + const query = toQuery(location.search); + return query.transactionName as string; + }, + }, + { + exact: true, + path: '/services/:serviceName/profiling', + component: ServiceDetailsProfilingRouteView, + breadcrumb: i18n.translate('xpack.apm.views.serviceProfiling.title', { + defaultMessage: 'Profiling', + }), + }, + { + exact: true, + path: '/services/:serviceName/service-map', + component: ServiceDetailsServiceMapRouteView, + breadcrumb: i18n.translate('xpack.apm.views.serviceMap.title', { + defaultMessage: 'Service Map', + }), + }, + /* + * Utilility routes + */ + { + exact: true, + path: '/link-to/trace/:traceId', + component: TraceLink, + breadcrumb: null, + }, +]; + +function RedirectToDefaultServiceRouteView( + props: RouteComponentProps<{ serviceName: string }> +) { + const { uiSettings } = useApmPluginContext().core; + const { serviceName } = props.match.params; + if (uiSettings.get(enableServiceOverview)) { + return redirectTo(`/services/${serviceName}/overview`)(props); + } + return redirectTo(`/services/${serviceName}/transactions`)(props); +} diff --git a/x-pack/plugins/apm/public/components/routing/app_root.tsx b/x-pack/plugins/apm/public/components/routing/app_root.tsx new file mode 100644 index 00000000000000..9529a67210748f --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/app_root.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ApmRoute } from '@elastic/apm-rum-react'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React from 'react'; +import { Route, Router, Switch } from 'react-router-dom'; +import { DefaultTheme, ThemeProvider } from 'styled-components'; +import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; +import { + KibanaContextProvider, + RedirectAppLinks, + useUiSetting$, +} from '../../../../../../src/plugins/kibana_react/public'; +import { ScrollToTopOnPathChange } from '../../components/app/Main/ScrollToTopOnPathChange'; +import { + ApmPluginContext, + ApmPluginContextValue, +} from '../../context/apm_plugin/apm_plugin_context'; +import { LicenseProvider } from '../../context/license/license_context'; +import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; +import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; +import { ApmPluginStartDeps } from '../../plugin'; +import { HeaderMenuPortal } from '../../../../observability/public'; +import { ApmHeaderActionMenu } from '../shared/apm_header_action_menu'; +import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { AnomalyDetectionJobsContextProvider } from '../../context/anomaly_detection_jobs/anomaly_detection_jobs_context'; +import { apmRouteConfig } from './apm_route_config'; + +const MainContainer = euiStyled.div` + height: 100%; +`; + +export function ApmAppRoot({ + apmPluginContextValue, + pluginsStart, +}: { + apmPluginContextValue: ApmPluginContextValue; + pluginsStart: ApmPluginStartDeps; +}) { + const { appMountParameters, core } = apmPluginContextValue; + const { history } = appMountParameters; + const i18nCore = core.i18n; + + return ( + + + + + + + + + + + + + + + {apmRouteConfig.map((route, i) => ( + + ))} + + + + + + + + + + + + ); +} + +function MountApmHeaderActionMenu() { + useBreadcrumbs(apmRouteConfig); + const { setHeaderActionMenu } = useApmPluginContext().appMountParameters; + + return ( + + + + ); +} + +function ApmThemeProvider({ children }: { children: React.ReactNode }) { + const [darkMode] = useUiSetting$('theme:darkMode'); + + return ( + ({ + ...outerTheme, + eui: darkMode ? euiDarkVars : euiLightVars, + darkMode, + })} + > + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/components/routing/redirect_to.tsx b/x-pack/plugins/apm/public/components/routing/redirect_to.tsx new file mode 100644 index 00000000000000..68ff2fce77f137 --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/redirect_to.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Redirect, RouteComponentProps } from 'react-router-dom'; + +/** + * Given a path, redirect to that location, preserving the search and maintaining + * backward-compatibilty with legacy (pre-7.9) hash-based URLs. + */ +export function redirectTo(to: string) { + return ({ location }: RouteComponentProps<{}>) => { + let resolvedUrl: URL | undefined; + + // Redirect root URLs with a hash to support backward compatibility with URLs + // from before we switched to the non-hash platform history. + if (location.pathname === '' && location.hash.length > 0) { + // We just want the search and pathname so the host doesn't matter + resolvedUrl = new URL(location.hash.slice(1), 'http://localhost'); + to = resolvedUrl.pathname; + } + + return ( + + ); + }; +} diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/route_config.test.tsx b/x-pack/plugins/apm/public/components/routing/route_config.test.tsx similarity index 93% rename from x-pack/plugins/apm/public/components/app/Main/route_config/route_config.test.tsx rename to x-pack/plugins/apm/public/components/routing/route_config.test.tsx index 62202d9489d51c..b1d5c1a83b43bb 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/route_config.test.tsx +++ b/x-pack/plugins/apm/public/components/routing/route_config.test.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import { routes } from './'; +import { apmRouteConfig } from './apm_route_config'; describe('routes', () => { describe('/', () => { - const route = routes.find((r) => r.path === '/'); + const route = apmRouteConfig.find((r) => r.path === '/'); describe('with no hash path', () => { it('redirects to /services', () => { diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx b/x-pack/plugins/apm/public/components/routing/route_handlers/agent_configuration.tsx similarity index 86% rename from x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx rename to x-pack/plugins/apm/public/components/routing/route_handlers/agent_configuration.tsx index e5d238e6aa89c3..8e0a08603bc76d 100644 --- a/x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx +++ b/x-pack/plugins/apm/public/components/routing/route_handlers/agent_configuration.tsx @@ -7,10 +7,10 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { useFetcher } from '../../../../../hooks/use_fetcher'; -import { toQuery } from '../../../../shared/Links/url_helpers'; -import { Settings } from '../../../Settings'; -import { AgentConfigurationCreateEdit } from '../../../Settings/AgentConfigurations/AgentConfigurationCreateEdit'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { toQuery } from '../../shared/Links/url_helpers'; +import { Settings } from '../../app/Settings'; +import { AgentConfigurationCreateEdit } from '../../app/Settings/AgentConfigurations/AgentConfigurationCreateEdit'; type EditAgentConfigurationRouteHandler = RouteComponentProps<{}>; diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.tsx new file mode 100644 index 00000000000000..0473e88c23d12f --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_main_template.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 React from 'react'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { ApmPluginStartDeps } from '../../../plugin'; +import { EnvironmentFilter } from '../../shared/EnvironmentFilter'; + +/* + * This template contains: + * - The Shared Observability Nav (https://github.com/elastic/kibana/blob/f7698bd8aa8787d683c728300ba4ca52b202369c/x-pack/plugins/observability/public/components/shared/page_template/README.md) + * - The APM Header Action Menu + * - Page title + * + * Optionally: + * - EnvironmentFilter + */ +export function ApmMainTemplate({ + pageTitle, + children, +}: { + pageTitle: React.ReactNode; + children: React.ReactNode; +}) { + const { services } = useKibana(); + const ObservabilityPageTemplate = + services.observability.navigation.PageTemplate; + + return ( + ], + }} + > + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx new file mode 100644 index 00000000000000..526d9eb3551d0a --- /dev/null +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template.tsx @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiTabs, + EuiTab, + EuiBetaBadge, +} from '@elastic/eui'; +import { ApmMainTemplate } from './apm_main_template'; +import { ApmServiceContextProvider } from '../../../context/apm_service/apm_service_context'; +import { enableServiceOverview } from '../../../../common/ui_settings_keys'; +import { isJavaAgentName, isRumAgentName } from '../../../../common/agent_name'; +import { ServiceIcons } from '../../shared/service_icons'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useErrorOverviewHref } from '../../shared/Links/apm/ErrorOverviewLink'; +import { useMetricOverviewHref } from '../../shared/Links/apm/MetricOverviewLink'; +import { useServiceMapHref } from '../../shared/Links/apm/ServiceMapLink'; +import { useServiceNodeOverviewHref } from '../../shared/Links/apm/ServiceNodeOverviewLink'; +import { useServiceOverviewHref } from '../../shared/Links/apm/service_overview_link'; +import { useServiceProfilingHref } from '../../shared/Links/apm/service_profiling_link'; +import { useTransactionsOverviewHref } from '../../shared/Links/apm/transaction_overview_link'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { Correlations } from '../../app/correlations'; +import { SearchBar } from '../../shared/search_bar'; + +interface Tab { + key: TabKey; + href: string; + text: React.ReactNode; + hidden?: boolean; +} + +type TabKey = + | 'errors' + | 'metrics' + | 'nodes' + | 'overview' + | 'service-map' + | 'profiling' + | 'transactions'; + +export function ApmServiceTemplate({ + children, + serviceName, + selectedTab, + searchBarOptions, +}: { + children: React.ReactNode; + serviceName: string; + selectedTab: TabKey; + searchBarOptions?: { + hidden?: boolean; + showTransactionTypeSelector?: boolean; + showTimeComparison?: boolean; + }; +}) { + return ( + + + + +

{serviceName}

+
+
+ + + +
+ + } + > + + + + + + + + + + + + + {children} + +
+ ); +} + +function TabNavigation({ + serviceName, + selectedTab, +}: { + serviceName: string; + selectedTab: TabKey; +}) { + const { agentName, transactionType } = useApmServiceContext(); + const { core, config } = useApmPluginContext(); + const { urlParams } = useUrlParams(); + + const tabs: Tab[] = [ + { + key: 'overview', + href: useServiceOverviewHref({ serviceName, transactionType }), + text: i18n.translate('xpack.apm.serviceDetails.overviewTabLabel', { + defaultMessage: 'Overview', + }), + hidden: !core.uiSettings.get(enableServiceOverview), + }, + { + key: 'transactions', + href: useTransactionsOverviewHref({ + serviceName, + latencyAggregationType: urlParams.latencyAggregationType, + transactionType, + }), + text: i18n.translate('xpack.apm.serviceDetails.transactionsTabLabel', { + defaultMessage: 'Transactions', + }), + }, + { + key: 'errors', + href: useErrorOverviewHref(serviceName), + text: i18n.translate('xpack.apm.serviceDetails.errorsTabLabel', { + defaultMessage: 'Errors', + }), + }, + { + key: 'nodes', + href: useServiceNodeOverviewHref(serviceName), + text: i18n.translate('xpack.apm.serviceDetails.nodesTabLabel', { + defaultMessage: 'JVMs', + }), + hidden: !isJavaAgentName(agentName), + }, + { + key: 'metrics', + href: useMetricOverviewHref(serviceName), + text: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', { + defaultMessage: 'Metrics', + }), + hidden: + !agentName || isRumAgentName(agentName) || isJavaAgentName(agentName), + }, + { + key: 'service-map', + href: useServiceMapHref(serviceName), + text: i18n.translate('xpack.apm.home.serviceMapTabLabel', { + defaultMessage: 'Service Map', + }), + }, + { + key: 'profiling', + href: useServiceProfilingHref({ serviceName }), + hidden: !config.profilingEnabled, + text: ( + + + {i18n.translate('xpack.apm.serviceDetails.profilingTabLabel', { + defaultMessage: 'Profiling', + })} + + + + + + ), + }, + ]; + + return ( + + {tabs + .filter((t) => !t.hidden) + .map(({ href, key, text }) => ( + + {text} + + ))} + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx deleted file mode 100644 index 4bc9764b704b00..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx +++ /dev/null @@ -1,53 +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 { EuiTitle } from '@elastic/eui'; -import React, { ComponentType } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { CoreStart } from '../../../../../../../src/core/public'; -import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; -import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; -import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; -import { createCallApmApi } from '../../../services/rest/createCallApmApi'; -import { ApmHeader } from './'; - -export default { - title: 'shared/ApmHeader', - component: ApmHeader, - decorators: [ - (Story: ComponentType) => { - createCallApmApi(({} as unknown) as CoreStart); - - return ( - - - - - - - - - - ); - }, - ], -}; - -export function Example() { - return ( - - -

- GET - /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all -

-
-
- ); -} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx deleted file mode 100644 index f94bba84526a78..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx +++ /dev/null @@ -1,36 +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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { ReactNode } from 'react'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { HeaderMenuPortal } from '../../../../../observability/public'; -import { ActionMenu } from '../../../application/action_menu'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { EnvironmentFilter } from '../EnvironmentFilter'; - -const HeaderFlexGroup = euiStyled(EuiFlexGroup)` - padding: ${({ theme }) => theme.eui.gutterTypes.gutterMedium}; - background: ${({ theme }) => theme.eui.euiColorEmptyShade}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; -`; - -export function ApmHeader({ children }: { children: ReactNode }) { - const { setHeaderActionMenu } = useApmPluginContext().appMountParameters; - - return ( - - - - - {children} - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx index 59c99463144cbe..c1bef7ac407ffa 100644 --- a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx @@ -13,6 +13,7 @@ import { useHistory, useLocation, useParams } from 'react-router-dom'; import { ENVIRONMENT_ALL, ENVIRONMENT_NOT_DEFINED, + omitEsFieldValue, } from '../../../../common/environment_filter_values'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -51,9 +52,9 @@ function getOptions(environments: string[]) { })); return [ - ENVIRONMENT_ALL, + omitEsFieldValue(ENVIRONMENT_ALL), ...(environments.includes(ENVIRONMENT_NOT_DEFINED.value) - ? [ENVIRONMENT_NOT_DEFINED] + ? [omitEsFieldValue(ENVIRONMENT_NOT_DEFINED)] : []), ...(environmentOptions.length > 0 ? [SEPARATOR_OPTION] : []), ...environmentOptions, @@ -78,12 +79,14 @@ export function EnvironmentFilter() { // the contents. const minWidth = 200; + const options = getOptions(environments); + return ( { updateEnvironmentUrl(history, location, event.target.value); diff --git a/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx similarity index 96% rename from x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx index 2ff3756855d142..95acc55196c543 100644 --- a/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx @@ -13,9 +13,9 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { IBasePath } from '../../../../../../src/core/public'; -import { AlertType } from '../../../common/alert_types'; -import { AlertingFlyout } from '../../components/alerting/alerting_flyout'; +import { IBasePath } from '../../../../../../../src/core/public'; +import { AlertType } from '../../../../common/alert_types'; +import { AlertingFlyout } from '../../alerting/alerting_flyout'; const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', { defaultMessage: 'Alerts', diff --git a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.test.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx similarity index 95% rename from x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.test.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx index 044abbb2ec792b..6a6ba3f9529ffa 100644 --- a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; import { MissingJobsAlert } from './anomaly_detection_setup_link'; -import * as hooks from '../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; -import { FETCH_STATUS } from '../../hooks/use_fetcher'; +import * as hooks from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; async function renderTooltipAnchor({ jobs, diff --git a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx similarity index 83% rename from x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx index 296e55fdff82ba..ade49bc7e3aa4f 100644 --- a/x-pack/plugins/apm/public/application/action_menu/anomaly_detection_setup_link.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/anomaly_detection_setup_link.tsx @@ -16,15 +16,15 @@ import React from 'react'; import { ENVIRONMENT_ALL, getEnvironmentLabel, -} from '../../../common/environment_filter_values'; -import { getAPMHref } from '../../components/shared/Links/apm/APMLink'; -import { useAnomalyDetectionJobsContext } from '../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; -import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; -import { useLicenseContext } from '../../context/license/use_license_context'; -import { useUrlParams } from '../../context/url_params_context/use_url_params'; -import { FETCH_STATUS } from '../../hooks/use_fetcher'; -import { APIReturnType } from '../../services/rest/createCallApmApi'; -import { units } from '../../style/variables'; +} from '../../../../common/environment_filter_values'; +import { getAPMHref } from '../Links/apm/APMLink'; +import { useAnomalyDetectionJobsContext } from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { useLicenseContext } from '../../../context/license/use_license_context'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; +import { units } from '../../../style/variables'; export type AnomalyDetectionApiResponse = APIReturnType<'GET /api/apm/settings/anomaly-detection/jobs'>; diff --git a/x-pack/plugins/apm/public/application/action_menu/index.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx similarity index 88% rename from x-pack/plugins/apm/public/application/action_menu/index.tsx rename to x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx index 2d9b619a3176d3..134941990a0f4c 100644 --- a/x-pack/plugins/apm/public/application/action_menu/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx @@ -9,13 +9,13 @@ import { EuiHeaderLink, EuiHeaderLinks } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useParams } from 'react-router-dom'; -import { getAlertingCapabilities } from '../../components/alerting/get_alerting_capabilities'; -import { getAPMHref } from '../../components/shared/Links/apm/APMLink'; -import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; +import { getAlertingCapabilities } from '../../alerting/get_alerting_capabilities'; +import { getAPMHref } from '../Links/apm/APMLink'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { AlertingPopoverAndFlyout } from './alerting_popover_flyout'; import { AnomalyDetectionSetupLink } from './anomaly_detection_setup_link'; -export function ActionMenu() { +export function ApmHeaderActionMenu() { const { core, plugins } = useApmPluginContext(); const { serviceName } = useParams<{ serviceName?: string }>(); const { search } = window.location; diff --git a/x-pack/plugins/apm/public/components/shared/main_tabs.tsx b/x-pack/plugins/apm/public/components/shared/main_tabs.tsx deleted file mode 100644 index f60da7c3087115..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/main_tabs.tsx +++ /dev/null @@ -1,24 +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 { EuiTabs } from '@elastic/eui'; -import React, { ReactNode } from 'react'; -import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; - -// Since our `EuiTab` components have `APMLink`s inside of them and not just -// `href`s, we need to override the color of the links inside or they will all -// be the primary color. -const StyledTabs = euiStyled(EuiTabs)` - padding: ${({ theme }) => `${theme.eui.gutterTypes.gutterMedium}`}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; - border-top: ${({ theme }) => theme.eui.euiBorderThin}; - background: ${({ theme }) => theme.eui.euiColorEmptyShade}; -`; - -export function MainTabs({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx new file mode 100644 index 00000000000000..105bdb008042e9 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/search_bar.test.tsx @@ -0,0 +1,120 @@ +/* + * 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 { getByTestId, fireEvent, getByText } from '@testing-library/react'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import React from 'react'; +import { Router } from 'react-router-dom'; +import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; +import { MockApmPluginContextWrapper } from '../../context/apm_plugin/mock_apm_plugin_context'; +import { ApmServiceContextProvider } from '../../context/apm_service/apm_service_context'; +import { UrlParamsProvider } from '../../context/url_params_context/url_params_context'; +import { IUrlParams } from '../../context/url_params_context/types'; +import * as useFetcherHook from '../../hooks/use_fetcher'; +import * as useServiceTransactionTypesHook from '../../context/apm_service/use_service_transaction_types_fetcher'; +import { renderWithTheme } from '../../utils/testHelpers'; +import { fromQuery } from './Links/url_helpers'; +import { CoreStart } from 'kibana/public'; +import { SearchBar } from './search_bar'; + +function setup({ + urlParams, + serviceTransactionTypes, + history, +}: { + urlParams: IUrlParams; + serviceTransactionTypes: string[]; + history: MemoryHistory; +}) { + history.replace({ + pathname: '/services/foo/transactions', + search: fromQuery(urlParams), + }); + + const KibanaReactContext = createKibanaReactContext({ + usageCollection: { reportUiCounter: () => {} }, + } as Partial); + + // mock transaction types + jest + .spyOn(useServiceTransactionTypesHook, 'useServiceTransactionTypesFetcher') + .mockReturnValue(serviceTransactionTypes); + + jest.spyOn(useFetcherHook, 'useFetcher').mockReturnValue({} as any); + + return renderWithTheme( + + + + + + + + + + + + ); +} + +describe('when transactionType is selected and multiple transaction types are given', () => { + let history: MemoryHistory; + beforeEach(() => { + history = createMemoryHistory(); + jest.spyOn(history, 'push'); + jest.spyOn(history, 'replace'); + }); + + it('renders a radio group with transaction types', () => { + const { container } = setup({ + history, + serviceTransactionTypes: ['firstType', 'secondType'], + urlParams: { + transactionType: 'secondType', + }, + }); + + // transaction type selector + const dropdown = getByTestId(container, 'headerFilterTransactionType'); + + // both options should be listed + expect(getByText(dropdown, 'firstType')).toBeInTheDocument(); + expect(getByText(dropdown, 'secondType')).toBeInTheDocument(); + + // second option should be selected + expect(dropdown).toHaveValue('secondType'); + }); + + it('should update the URL when a transaction type is selected', () => { + const { container } = setup({ + history, + serviceTransactionTypes: ['firstType', 'secondType'], + urlParams: { + transactionType: 'secondType', + }, + }); + + expect(history.location.search).toEqual( + '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now' + ); + + // transaction type selector + const dropdown = getByTestId(container, 'headerFilterTransactionType'); + expect(getByText(dropdown, 'firstType')).toBeInTheDocument(); + expect(getByText(dropdown, 'secondType')).toBeInTheDocument(); + + // change dropdown value + fireEvent.change(dropdown, { target: { value: 'firstType' } }); + + // assert that value was changed + expect(dropdown).toHaveValue('firstType'); + expect(history.push).toHaveBeenCalled(); + expect(history.location.search).toEqual( + '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now' + ); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index f0fc18cf266b9e..17497e1fb4b30a 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -15,7 +15,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; import { enableInspectEsQueries } from '../../../../observability/public'; import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; import { useKibanaUrl } from '../../hooks/useKibanaUrl'; @@ -26,12 +25,9 @@ import { KueryBar } from './KueryBar'; import { TimeComparison } from './time_comparison'; import { TransactionTypeSelect } from './transaction_type_select'; -const EuiFlexGroupSpaced = euiStyled(EuiFlexGroup)` - margin: ${({ theme }) => - `${theme.eui.euiSizeS} ${theme.eui.euiSizeS} -${theme.eui.gutterTypes.gutterMedium} ${theme.eui.euiSizeS}`}; -`; - interface Props { + hidden?: boolean; + showKueryBar?: boolean; showTimeComparison?: boolean; showTransactionTypeSelector?: boolean; } @@ -49,7 +45,7 @@ function DebugQueryCallout() { } return ( - + - + ); } export function SearchBar({ + hidden = false, + showKueryBar = true, showTimeComparison = false, showTransactionTypeSelector = false, }: Props) { const { isSmall, isMedium, isLarge, isXl, isXXL } = useBreakPoints(); + + if (hidden) { + return null; + } + return ( <> - )} - - - + + {showKueryBar && ( + + + + )} @@ -128,7 +134,7 @@ export function SearchBar({ - + ); diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/alert_details.tsx similarity index 84% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/alert_details.tsx index 0066480230c6bf..9f6378ccb44971 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/alert_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/alert_details.tsx @@ -14,12 +14,12 @@ import { RULE_ID, RULE_NAME, } from '@kbn/rule-data-utils/target/technical_field_names'; -import { parseTechnicalFields } from '../../../../../../rule_registry/common'; -import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; -import { asPercent, asDuration } from '../../../../../common/utils/formatters'; -import { TimestampTooltip } from '../../../shared/TimestampTooltip'; +import { parseTechnicalFields } from '../../../../../rule_registry/common'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; +import { asPercent, asDuration } from '../../../../common/utils/formatters'; +import { TimestampTooltip } from '../TimestampTooltip'; interface AlertDetailProps { alerts: APIReturnType<'GET /api/apm/services/{serviceName}/alerts'>['alerts']; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx similarity index 97% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx index 2e19bc684d6815..2e8fcfa1df6725 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/cloud_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/cloud_details.tsx @@ -9,7 +9,7 @@ import { EuiBadge, EuiDescriptionList } from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/container_details.tsx similarity index 94% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/container_details.tsx index efc9a46526cf87..b590a67409d9ee 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/container_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/container_details.tsx @@ -9,8 +9,8 @@ import { EuiDescriptionList } from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { asInteger } from '../../../../../common/utils/formatters'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { asInteger } from '../../../../common/utils/formatters'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx similarity index 93% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx index 79f93ea76ee51f..05305558564f13 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/icon_popover.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/icon_popover.tsx @@ -13,8 +13,8 @@ import { EuiPopoverTitle, } from '@elastic/eui'; import React from 'react'; -import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; -import { px } from '../../../../style/variables'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { px } from '../../../style/variables'; interface IconPopoverProps { title: string; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx similarity index 95% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx index 6027e8b1d07c59..d66625f613cdcc 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.test.tsx @@ -11,14 +11,14 @@ import { merge } from 'lodash'; // import { renderWithTheme } from '../../../../utils/testHelpers'; import React, { ReactNode } from 'react'; import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; -import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; -import { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; +import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; +import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; import { mockApmPluginContextValue, MockApmPluginContextWrapper, -} from '../../../../context/apm_plugin/mock_apm_plugin_context'; -import * as fetcherHook from '../../../../hooks/use_fetcher'; -import { ServiceIcons } from './'; +} from '../../../context/apm_plugin/mock_apm_plugin_context'; +import * as fetcherHook from '../../../hooks/use_fetcher'; +import { ServiceIcons } from '.'; import { EuiThemeProvider } from 'src/plugins/kibana_react/common'; const KibanaReactContext = createKibanaReactContext({ diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx similarity index 91% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/index.tsx index f7bed4e09a696d..d64605da2bc3f9 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/index.tsx @@ -8,12 +8,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ReactChild, useState } from 'react'; -import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; -import { useTheme } from '../../../../hooks/use_theme'; -import { ContainerType } from '../../../../../common/service_metadata'; -import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; -import { getAgentIcon } from '../../../shared/AgentIcon/get_agent_icon'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useTheme } from '../../../hooks/use_theme'; +import { ContainerType } from '../../../../common/service_metadata'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { getAgentIcon } from '../AgentIcon/get_agent_icon'; import { CloudDetails } from './cloud_details'; import { ContainerDetails } from './container_details'; import { IconPopover } from './icon_popover'; diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx b/x-pack/plugins/apm/public/components/shared/service_icons/service_details.tsx similarity index 96% rename from x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx rename to x-pack/plugins/apm/public/components/shared/service_icons/service_details.tsx index ed503a5cb34a03..1828465fff450a 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_icons/service_details.tsx +++ b/x-pack/plugins/apm/public/components/shared/service_icons/service_details.tsx @@ -9,7 +9,7 @@ import { EuiDescriptionList } from '@elastic/eui'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; type ServiceDetailsReturnType = APIReturnType<'GET /api/apm/services/{serviceName}/metadata/details'>; diff --git a/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx b/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx index 13a2fa3b227da9..64990651b52bbd 100644 --- a/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx +++ b/x-pack/plugins/apm/public/hooks/use_breadcrumbs.test.tsx @@ -9,7 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import produce from 'immer'; import React, { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { routes } from '../components/app/Main/route_config'; +import { apmRouteConfig } from '../components/routing/apm_route_config'; import { ApmPluginContextValue } from '../context/apm_plugin/apm_plugin_context'; import { mockApmPluginContextValue, @@ -36,7 +36,9 @@ function createWrapper(path: string) { } function mountBreadcrumb(path: string) { - renderHook(() => useBreadcrumbs(routes), { wrapper: createWrapper(path) }); + renderHook(() => useBreadcrumbs(apmRouteConfig), { + wrapper: createWrapper(path), + }); } const changeTitle = jest.fn(); diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 10af1837dab42f..845b18b707f930 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { of } from 'rxjs'; import type { ConfigSchema } from '.'; import { AppMountParameters, @@ -34,6 +35,7 @@ import type { FetchDataParams, HasDataParams, ObservabilityPublicSetup, + ObservabilityPublicStart, } from '../../observability/public'; import type { TriggersAndActionsUIPublicPluginSetup, @@ -48,24 +50,25 @@ export type ApmPluginStart = void; export interface ApmPluginSetupDeps { alerting?: AlertingPluginPublicSetup; - ml?: MlPluginSetup; data: DataPublicPluginSetup; features: FeaturesPluginSetup; home?: HomePublicPluginSetup; licensing: LicensingPluginSetup; - triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + ml?: MlPluginSetup; observability: ObservabilityPublicSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; } export interface ApmPluginStartDeps { alerting?: AlertingPluginPublicStart; - ml?: MlPluginStart; data: DataPublicPluginStart; + embeddable: EmbeddableStart; home: void; licensing: void; - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; - embeddable: EmbeddableStart; maps?: MapsStartApi; + ml?: MlPluginStart; + observability: ObservabilityPublicStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } export class ApmPlugin implements Plugin { @@ -83,6 +86,21 @@ export class ApmPlugin implements Plugin { pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry); } + // register observability nav + plugins.observability.navigation.registerSections( + of([ + { + label: 'APM', + sortKey: 200, + entries: [ + { label: 'Services', app: 'apm', path: '/services' }, + { label: 'Traces', app: 'apm', path: '/traces' }, + { label: 'Service Map', app: 'apm', path: '/service-map' }, + ], + }, + ]) + ); + const getApmDataHelper = async () => { const { fetchObservabilityOverviewPageData, diff --git a/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts b/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts deleted file mode 100644 index 7b4a07dc7bbc53..00000000000000 --- a/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts +++ /dev/null @@ -1,22 +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 { flatten } from 'lodash'; -import { TimeSeries } from '../../typings/timeseries'; - -export function getRangeFromTimeSeries(timeseries: TimeSeries[]) { - const dataPoints = flatten(timeseries.map((series) => series.data)); - - if (dataPoints.length) { - return { - start: dataPoints[0].x, - end: dataPoints[dataPoints.length - 1].x, - }; - } - - return null; -} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c2cad05ff9e304..4c927d5094ca4c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5400,22 +5400,9 @@ "xpack.apm.apply.label": "適用", "xpack.apm.applyFilter": "{title} フィルターを適用", "xpack.apm.applyOptions": "オプションを適用", - "xpack.apm.breadcrumb.errorsTitle": "エラー", - "xpack.apm.breadcrumb.listSettingsTitle": "設定", - "xpack.apm.breadcrumb.metricsTitle": "メトリック", - "xpack.apm.breadcrumb.nodesTitle": "JVM", - "xpack.apm.breadcrumb.overviewTitle": "概要", "xpack.apm.breadcrumb.serviceMapTitle": "サービスマップ", - "xpack.apm.breadcrumb.serviceProfilingTitle": "プロファイリング", "xpack.apm.breadcrumb.servicesTitle": "サービス", - "xpack.apm.breadcrumb.settings.agentConfigurationTitle": "エージェントの編集", - "xpack.apm.breadcrumb.settings.anomalyDetection": "異常検知", - "xpack.apm.breadcrumb.settings.createAgentConfigurationTitle": "エージェント構成の作成", - "xpack.apm.breadcrumb.settings.customizeUI": "UI をカスタマイズ", - "xpack.apm.breadcrumb.settings.editAgentConfigurationTitle": "エージェント構成の編集", - "xpack.apm.breadcrumb.settings.indicesTitle": "インデックス", "xpack.apm.breadcrumb.tracesTitle": "トレース", - "xpack.apm.breadcrumb.transactionsTitle": "トランザクション", "xpack.apm.chart.annotation.version": "バージョン", "xpack.apm.chart.cpuSeries.processAverageLabel": "プロセス平均", "xpack.apm.chart.cpuSeries.processMaxLabel": "プロセス最大", @@ -5524,8 +5511,6 @@ "xpack.apm.home.alertsMenu.transactionErrorRate": "トランザクションエラー率", "xpack.apm.home.alertsMenu.viewActiveAlerts": "アクティブアラートを表示", "xpack.apm.home.serviceMapTabLabel": "サービスマップ", - "xpack.apm.home.servicesTabLabel": "サービス", - "xpack.apm.home.tracesTabLabel": "トレース", "xpack.apm.instancesLatencyDistributionChartLegend": "インスタンス", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "前の期間", "xpack.apm.instancesLatencyDistributionChartTitle": "インスタンスのレイテンシ分布", @@ -5589,7 +5574,6 @@ "xpack.apm.profiling.highlightFrames": "検索", "xpack.apm.profiling.table.name": "名前", "xpack.apm.profiling.table.value": "自己", - "xpack.apm.profilingOverviewTitle": "プロファイリング", "xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "利用可能なデータがありません", "xpack.apm.propertiesTable.agentFeature.noResultFound": "\"{value}\"に対する結果が見つかりませんでした。", "xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "例外のスタックトレース", @@ -5654,7 +5638,6 @@ "xpack.apm.selectPlaceholder": "オプションを選択:", "xpack.apm.serviceDetails.errorsTabLabel": "エラー", "xpack.apm.serviceDetails.metrics.cpuUsageChartTitle": "CPU 使用状況", - "xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle": "エラーのオカレンス", "xpack.apm.serviceDetails.metrics.memoryUsageChartTitle": "システムメモリー使用状況", "xpack.apm.serviceDetails.metricsTabLabel": "メトリック", "xpack.apm.serviceDetails.nodesTabLabel": "JVM", @@ -5891,7 +5874,6 @@ "xpack.apm.settings.customizeUI.customLink.table.url": "URL", "xpack.apm.settings.indices": "インデックス", "xpack.apm.settings.pageTitle": "設定", - "xpack.apm.settings.returnLinkLabel": "インベントリに戻る", "xpack.apm.settingsLinkLabel": "設定", "xpack.apm.setupInstructionsButtonLabel": "セットアップの手順", "xpack.apm.significanTerms.license.text": "相関関係APIを使用するには、Elastic Platinumライセンスのサブスクリプションが必要です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d3a3d9ae30c378..57a1b6a8751fd4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5429,22 +5429,9 @@ "xpack.apm.apply.label": "应用", "xpack.apm.applyFilter": "应用 {title} 筛选", "xpack.apm.applyOptions": "应用选项", - "xpack.apm.breadcrumb.errorsTitle": "错误", - "xpack.apm.breadcrumb.listSettingsTitle": "设置", - "xpack.apm.breadcrumb.metricsTitle": "指标", - "xpack.apm.breadcrumb.nodesTitle": "JVM", - "xpack.apm.breadcrumb.overviewTitle": "概览", "xpack.apm.breadcrumb.serviceMapTitle": "服务地图", - "xpack.apm.breadcrumb.serviceProfilingTitle": "分析", "xpack.apm.breadcrumb.servicesTitle": "服务", - "xpack.apm.breadcrumb.settings.agentConfigurationTitle": "代理配置", - "xpack.apm.breadcrumb.settings.anomalyDetection": "异常检测", - "xpack.apm.breadcrumb.settings.createAgentConfigurationTitle": "创建代理配置", - "xpack.apm.breadcrumb.settings.customizeUI": "定制 UI", - "xpack.apm.breadcrumb.settings.editAgentConfigurationTitle": "编辑代理配置", - "xpack.apm.breadcrumb.settings.indicesTitle": "索引", "xpack.apm.breadcrumb.tracesTitle": "追溯", - "xpack.apm.breadcrumb.transactionsTitle": "事务", "xpack.apm.chart.annotation.version": "版本", "xpack.apm.chart.cpuSeries.processAverageLabel": "进程平均值", "xpack.apm.chart.cpuSeries.processMaxLabel": "进程最大值", @@ -5554,8 +5541,6 @@ "xpack.apm.home.alertsMenu.transactionErrorRate": "事务错误率", "xpack.apm.home.alertsMenu.viewActiveAlerts": "查看活动告警", "xpack.apm.home.serviceMapTabLabel": "服务地图", - "xpack.apm.home.servicesTabLabel": "服务", - "xpack.apm.home.tracesTabLabel": "追溯", "xpack.apm.instancesLatencyDistributionChartLegend": "实例", "xpack.apm.instancesLatencyDistributionChartLegend.previousPeriod": "上一时段", "xpack.apm.instancesLatencyDistributionChartTitle": "实例延迟分布", @@ -5622,7 +5607,6 @@ "xpack.apm.profiling.highlightFrames": "搜索", "xpack.apm.profiling.table.name": "名称", "xpack.apm.profiling.table.value": "自我", - "xpack.apm.profilingOverviewTitle": "分析", "xpack.apm.propertiesTable.agentFeature.noDataAvailableLabel": "没有可用数据", "xpack.apm.propertiesTable.agentFeature.noResultFound": "没有“{value}”的结果。", "xpack.apm.propertiesTable.tabs.exceptionStacktraceLabel": "异常堆栈跟踪", @@ -5687,7 +5671,6 @@ "xpack.apm.selectPlaceholder": "选择选项:", "xpack.apm.serviceDetails.errorsTabLabel": "错误", "xpack.apm.serviceDetails.metrics.cpuUsageChartTitle": "CPU 使用", - "xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle": "错误发生次数", "xpack.apm.serviceDetails.metrics.memoryUsageChartTitle": "系统内存使用", "xpack.apm.serviceDetails.metricsTabLabel": "指标", "xpack.apm.serviceDetails.nodesTabLabel": "JVM", @@ -5925,7 +5908,6 @@ "xpack.apm.settings.customizeUI.customLink.table.url": "URL", "xpack.apm.settings.indices": "索引", "xpack.apm.settings.pageTitle": "设置", - "xpack.apm.settings.returnLinkLabel": "返回库存", "xpack.apm.settingsLinkLabel": "设置", "xpack.apm.setupInstructionsButtonLabel": "设置说明", "xpack.apm.significanTerms.license.text": "要使用相关性 API,必须订阅 Elastic 白金级许可证。", From d6164aeecc3eab4df4bc91606099479776a2206c Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Fri, 4 Jun 2021 10:12:04 +0200 Subject: [PATCH 58/77] [Ingest pipelines] add media_type to set processor (#101035) * start working on conditionally showing the field * add tests and document regex matcher * add tests for set processor * fix broken tests * move path below componentProps * Add little comment about whitespaces handling * template snippets can also contain strings other Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__jest__/processors/processor.helpers.tsx | 4 + .../__jest__/processors/set.test.tsx | 146 ++++++++++++++++++ .../processor_form/processors/set.tsx | 61 +++++++- .../components/pipeline_editor/utils.test.ts | 18 ++- .../components/pipeline_editor/utils.ts | 17 ++ 5 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index 9dd0d6cc72de17..c00f09b2d2b06c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -154,6 +154,10 @@ type TestSubject = | 'separatorValueField.input' | 'quoteValueField.input' | 'emptyValueField.input' + | 'valueFieldInput' + | 'mediaTypeSelectorField' + | 'ignoreEmptyField.input' + | 'overrideField.input' | 'fieldsValueField.input' | 'saltValueField.input' | 'methodsValueField' diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx new file mode 100644 index 00000000000000..d7351c9dbf65f6 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/set.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; + +// Default parameter values automatically added to the set processor when saved +const defaultSetParameters = { + value: '', + if: undefined, + tag: undefined, + override: undefined, + media_type: undefined, + description: undefined, + ignore_missing: undefined, + ignore_failure: undefined, + ignore_empty_value: undefined, +}; + +const SET_TYPE = 'set'; + +describe('Processor: Set', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + + testBed.component.update(); + + // Open flyout to add new processor + testBed.actions.addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await testBed.actions.addProcessorType(SET_TYPE); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Click submit button with only the type defined + await saveNewProcessor(); + + // Expect form error as "field" is required parameter + expect(form.getErrorsMessages()).toEqual(['A field value is required.']); + }); + + test('saves with default parameter value', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, SET_TYPE); + expect(processors[0][SET_TYPE]).toEqual({ + ...defaultSetParameters, + field: 'field_1', + }); + }); + + test('should allow to set mediaType when value is a template snippet', async () => { + const { + actions: { saveNewProcessor }, + form, + exists, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Shouldnt be able to set mediaType if value is not a template string + form.setInputValue('valueFieldInput', 'hello'); + expect(exists('mediaTypeSelectorField')).toBe(false); + + // Set value to a template snippet and media_type to a non-default value + form.setInputValue('valueFieldInput', '{{{hello}}}'); + form.setSelectValue('mediaTypeSelectorField', 'text/plain'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, SET_TYPE); + expect(processors[0][SET_TYPE]).toEqual({ + ...defaultSetParameters, + field: 'field_1', + value: '{{{hello}}}', + media_type: 'text/plain', + }); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Set optional parameteres + form.setInputValue('valueFieldInput', '{{{hello}}}'); + form.toggleEuiSwitch('overrideField.input'); + form.toggleEuiSwitch('ignoreEmptyField.input'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, SET_TYPE); + expect(processors[0][SET_TYPE]).toEqual({ + ...defaultSetParameters, + field: 'field_1', + value: '{{{hello}}}', + ignore_empty_value: true, + override: false, + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx index 89ca373b9e6539..fda34f8700b33c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx @@ -10,7 +10,15 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCode } from '@elastic/eui'; -import { FIELD_TYPES, ToggleField, UseField, Field } from '../../../../../../shared_imports'; +import { + FIELD_TYPES, + useFormData, + SelectField, + ToggleField, + UseField, + Field, +} from '../../../../../../shared_imports'; +import { hasTemplateSnippet } from '../../../utils'; import { FieldsConfig, to, from } from './shared'; @@ -35,6 +43,20 @@ const fieldsConfig: FieldsConfig = { /> ), }, + mediaType: { + type: FIELD_TYPES.SELECT, + defaultValue: 'application/json', + serializer: from.undefinedIfValue('application/json'), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.mediaTypeFieldLabel', { + defaultMessage: 'Media Type', + }), + helpText: ( + + ), + }, /* Optional fields config */ override: { type: FIELD_TYPES.TOGGLE, @@ -83,6 +105,8 @@ const fieldsConfig: FieldsConfig = { * Disambiguate name from the Set data structure */ export const SetProcessor: FunctionComponent = () => { + const [{ fields }] = useFormData({ watch: 'fields.value' }); + return ( <> { path="fields.value" /> - + {hasTemplateSnippet(fields?.value) && ( + + )} + + ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts index 6f21285398e1fa..6e367a83bf8d44 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getValue, setValue } from './utils'; +import { getValue, setValue, hasTemplateSnippet } from './utils'; describe('get and set values', () => { const testObject = Object.freeze([{ onFailure: [{ onFailure: 1 }] }]); @@ -35,3 +35,19 @@ describe('get and set values', () => { }); }); }); + +describe('template snippets', () => { + it('knows when a string contains an invalid template snippet', () => { + expect(hasTemplateSnippet('')).toBe(false); + expect(hasTemplateSnippet('{}')).toBe(false); + expect(hasTemplateSnippet('{{{}}}')).toBe(false); + expect(hasTemplateSnippet('{{hello}}')).toBe(false); + }); + + it('knows when a string contains a valid template snippet', () => { + expect(hasTemplateSnippet('{{{hello}}}')).toBe(true); + expect(hasTemplateSnippet('hello{{{world}}}')).toBe(true); + expect(hasTemplateSnippet('{{{hello}}}world')).toBe(true); + expect(hasTemplateSnippet('{{{hello.world}}}')).toBe(true); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts index e07b9eba90622c..1259dbd5a9b91c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts @@ -102,3 +102,20 @@ export const checkIfSamePath = (pathA: ProcessorSelector, pathB: ProcessorSelect if (pathA.length !== pathB.length) return false; return pathA.join('.') === pathB.join('.'); }; + +/* + * Given a string it checks if it contains a valid mustache template snippet. + * + * Note: This allows strings with spaces such as: {{{hello world}}}. I figured we + * should use .+ instead of \S (disallow all whitespaces) because the backend seems + * to allow spaces inside the template snippet anyway. + * + * See: https://www.elastic.co/guide/en/elasticsearch/reference/master/ingest.html#template-snippets + */ +export const hasTemplateSnippet = (str: string = '') => { + // Matches when: + // * contains a {{{ + // * Followed by all strings of length >= 1 + // * And followed by }}} + return /{{{.+}}}/.test(str); +}; From e3198bcb57359f8492b3388a073a19aa1de3121b Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Fri, 4 Jun 2021 10:19:04 +0200 Subject: [PATCH 59/77] [Lens] rewrite warning messages with i18n (#101314) --- .../public/pie_visualization/visualization.tsx | 15 ++++++++++----- .../xy_visualization/visualization.test.ts | 17 +++++++++++------ .../public/xy_visualization/visualization.tsx | 15 ++++++++++----- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index f413b122d913cc..6e04d1a4ff9583 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render } from 'react-dom'; import { i18n } from '@kbn/i18n'; -import { I18nProvider } from '@kbn/i18n/react'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { Visualization, OperationMetadata, AccessorConfig } from '../types'; import { toExpression, toPreviewExpression } from './to_expression'; @@ -265,10 +265,15 @@ export const getPieVisualization = ({ } } return metricColumnsWithArrayValues.map((label) => ( - <> - {label} contains array values. Your visualization may not render as - expected. - + {label}, + }} + /> )); }, diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 8fbc8e8b2ef7ab..c1041e1fefcfd1 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -929,12 +929,17 @@ describe('xy_visualization', () => { ); expect(warningMessages).toHaveLength(1); expect(warningMessages && warningMessages[0]).toMatchInlineSnapshot(` - - - Label B - - contains array values. Your visualization may not render as expected. - + + Label B + , + } + } + /> `); }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index fa9d46be11d686..ad2c9fd7139853 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { uniq } from 'lodash'; import { render } from 'react-dom'; import { Position } from '@elastic/charts'; -import { I18nProvider } from '@kbn/i18n/react'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; @@ -439,10 +439,15 @@ export const getXyVisualization = ({ } } return accessorsWithArrayValues.map((label) => ( - <> - {label} contains array values. Your visualization may not render as - expected. - + {label}, + }} + /> )); }, }); From 6927d6cf1ce6d8dc2962cfabe13adcf208cedef6 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Fri, 4 Jun 2021 11:42:51 +0300 Subject: [PATCH 60/77] [Visualize] Adds a unit test to compare the by value and by ref migrations (#101247) * [Visualize] Add unti test to compare the by value and by ref migrations * Fix file name Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../visualize_embeddable_factory.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts diff --git a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts new file mode 100644 index 00000000000000..fe0f1a766e8aca --- /dev/null +++ b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 semverGte from 'semver/functions/gte'; +import { visualizeEmbeddableFactory } from './visualize_embeddable_factory'; +import { visualizationSavedObjectTypeMigrations } from '../migrations/visualization_saved_object_migrations'; + +describe('saved object migrations and embeddable migrations', () => { + test('should have same versions registered (>7.13.0)', () => { + const savedObjectMigrationVersions = Object.keys(visualizationSavedObjectTypeMigrations).filter( + (version) => { + return semverGte(version, '7.13.1'); + } + ); + const embeddableMigrationVersions = visualizeEmbeddableFactory()?.migrations; + if (embeddableMigrationVersions) { + expect(savedObjectMigrationVersions.sort()).toEqual( + Object.keys(embeddableMigrationVersions).sort() + ); + } + }); +}); From 71adda0366c638c6e755e070869ebcf610e58efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Fri, 4 Jun 2021 11:27:11 +0200 Subject: [PATCH 61/77] [APM] Update ESLint and tsc commands in APM readme (#101207) * [APM] Change typescript command in readme * Update eslint command --- .../plugins/apm/e2e/cypress/integration/csm_dashboard.feature | 1 - x-pack/plugins/apm/readme.md | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature b/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature index 4c598d8d168a42..2b95216bc37196 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature +++ b/x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature @@ -29,7 +29,6 @@ Feature: CSM Dashboard When a user browses the APM UI application for RUM Data Then should display percentile for page load chart And should display tooltip on hover - And should display chart legend Scenario: Breakdown filter Given a user clicks the page load breakdown filter diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index ef2675f4f6c65c..9cfb6210e25415 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -124,7 +124,7 @@ _Note: Run the following commands from `kibana/`._ ### Typescript ``` -yarn tsc --noEmit --emitDeclarationOnly false --project x-pack/plugins/apm/tsconfig.json --skipLibCheck +node scripts/type_check.js --project x-pack/plugins/apm/tsconfig.json ``` ### Prettier @@ -136,7 +136,7 @@ yarn prettier "./x-pack/plugins/apm/**/*.{tsx,ts,js}" --write ### ESLint ``` -yarn eslint ./x-pack/plugins/apm --fix +node scripts/eslint.js x-pack/legacy/plugins/apm ``` ## Setup default APM users From d62bb452dd527ab4f7cbd4a4f1d7a2ba9fac05e4 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 4 Jun 2021 13:16:31 +0200 Subject: [PATCH 62/77] make sure migrations stay in sync (#101362) --- .../lens_embeddable_factory.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts new file mode 100644 index 00000000000000..9ce405804bde16 --- /dev/null +++ b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import semverGte from 'semver/functions/gte'; +import { lensEmbeddableFactory } from './lens_embeddable_factory'; +import { migrations } from '../migrations/saved_object_migrations'; + +describe('saved object migrations and embeddable migrations', () => { + test('should have same versions registered (>7.13.0)', () => { + const savedObjectMigrationVersions = Object.keys(migrations).filter((version) => { + return semverGte(version, '7.13.1'); + }); + const embeddableMigrationVersions = lensEmbeddableFactory()?.migrations; + if (embeddableMigrationVersions) { + expect(savedObjectMigrationVersions.sort()).toEqual( + Object.keys(embeddableMigrationVersions).sort() + ); + } + }); +}); From aa8aa7f23dc0ac69e375234a57f1aeef20fabbdd Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Fri, 4 Jun 2021 14:46:05 +0200 Subject: [PATCH 63/77] Saved object export: apply export hooks to referenced / nested objects (#100769) * execute export transform for nested references * fix sort * fix duplicate references * add FTR test --- .../collect_exported_objects.test.mocks.ts | 12 + .../export/collect_exported_objects.test.ts | 528 +++++++++++++++ .../export/collect_exported_objects.ts | 128 ++++ .../export/fetch_nested_dependencies.test.ts | 606 ------------------ .../export/fetch_nested_dependencies.ts | 50 -- .../export/saved_objects_exporter.ts | 32 +- .../nested_export_transform/data.json | 87 +++ .../nested_export_transform/mappings.json | 499 ++++++++++++++ .../export_transform.ts | 261 ++++---- 9 files changed, 1420 insertions(+), 783 deletions(-) create mode 100644 src/core/server/saved_objects/export/collect_exported_objects.test.mocks.ts create mode 100644 src/core/server/saved_objects/export/collect_exported_objects.test.ts create mode 100644 src/core/server/saved_objects/export/collect_exported_objects.ts delete mode 100644 src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts delete mode 100644 src/core/server/saved_objects/export/fetch_nested_dependencies.ts create mode 100644 test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json create mode 100644 test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json diff --git a/src/core/server/saved_objects/export/collect_exported_objects.test.mocks.ts b/src/core/server/saved_objects/export/collect_exported_objects.test.mocks.ts new file mode 100644 index 00000000000000..1f61788e556503 --- /dev/null +++ b/src/core/server/saved_objects/export/collect_exported_objects.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 applyExportTransformsMock = jest.fn(); +jest.doMock('./apply_export_transforms', () => ({ + applyExportTransforms: applyExportTransformsMock, +})); diff --git a/src/core/server/saved_objects/export/collect_exported_objects.test.ts b/src/core/server/saved_objects/export/collect_exported_objects.test.ts new file mode 100644 index 00000000000000..0929ff0d40910d --- /dev/null +++ b/src/core/server/saved_objects/export/collect_exported_objects.test.ts @@ -0,0 +1,528 @@ +/* + * 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 { applyExportTransformsMock } from './collect_exported_objects.test.mocks'; +import { savedObjectsClientMock } from '../../mocks'; +import { httpServerMock } from '../../http/http_server.mocks'; +import { SavedObject, SavedObjectError } from '../../../types'; +import type { SavedObjectsExportTransform } from './types'; +import { collectExportedObjects } from './collect_exported_objects'; + +const createObject = (parts: Partial): SavedObject => ({ + id: 'id', + type: 'type', + references: [], + attributes: {}, + ...parts, +}); + +const createError = (parts: Partial = {}): SavedObjectError => ({ + error: 'error', + message: 'message', + statusCode: 404, + ...parts, +}); + +const toIdTuple = (obj: SavedObject) => ({ type: obj.type, id: obj.id }); + +describe('collectExportedObjects', () => { + let savedObjectsClient: ReturnType; + let request: ReturnType; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + request = httpServerMock.createKibanaRequest(); + applyExportTransformsMock.mockImplementation(({ objects }) => objects); + savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [] }); + }); + + afterEach(() => { + applyExportTransformsMock.mockReset(); + savedObjectsClient.bulkGet.mockReset(); + }); + + describe('when `includeReferences` is `true`', () => { + it('calls `applyExportTransforms` with the correct parameters', async () => { + const obj1 = createObject({ + type: 'foo', + id: '1', + }); + const obj2 = createObject({ + type: 'foo', + id: '2', + }); + + const fooTransform: SavedObjectsExportTransform = jest.fn(); + + await collectExportedObjects({ + objects: [obj1, obj2], + savedObjectsClient, + request, + exportTransforms: { foo: fooTransform }, + includeReferences: true, + }); + + expect(applyExportTransformsMock).toHaveBeenCalledTimes(1); + expect(applyExportTransformsMock).toHaveBeenCalledWith({ + objects: [obj1, obj2], + transforms: { foo: fooTransform }, + request, + }); + }); + + it('returns the collected objects', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + }); + + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, dolly3]); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(missingRefs).toHaveLength(0); + expect(objects.map(toIdTuple)).toEqual([foo1, dolly3, bar2].map(toIdTuple)); + }); + + it('returns the missing references', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + { + type: 'missing', + id: '1', + name: 'missing-1', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'missing', + id: '2', + name: 'missing-2', + }, + ], + }); + const missing1 = createObject({ + type: 'missing', + id: '1', + error: createError(), + }); + const missing2 = createObject({ + type: 'missing', + id: '2', + error: createError(), + }); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2, missing1], + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [missing2], + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(missingRefs).toEqual([missing1, missing2].map(toIdTuple)); + expect(objects.map(toIdTuple)).toEqual([foo1, bar2].map(toIdTuple)); + }); + + it('does not call `client.bulkGet` when no objects have references', async () => { + const obj1 = createObject({ + type: 'foo', + id: '1', + }); + const obj2 = createObject({ + type: 'foo', + id: '2', + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [obj1, obj2], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(missingRefs).toHaveLength(0); + expect(objects.map(toIdTuple)).toEqual([ + { + type: 'foo', + id: '1', + }, + { + type: 'foo', + id: '2', + }, + ]); + + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + + it('calls `applyExportTransforms` for each iteration', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + + await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [toIdTuple(bar2)], + expect.any(Object) + ); + + expect(applyExportTransformsMock).toHaveBeenCalledTimes(2); + expect(applyExportTransformsMock).toHaveBeenCalledWith({ + objects: [foo1], + transforms: {}, + request, + }); + expect(applyExportTransformsMock).toHaveBeenCalledWith({ + objects: [bar2], + transforms: {}, + request, + }); + }); + + it('ignores references that are already included in the export', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'foo', + id: '1', + name: 'foo-1', + }, + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + ], + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + references: [ + { + type: 'foo', + id: '1', + name: 'foo-1', + }, + ], + }); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [foo1, dolly3], + }); + + const { objects } = await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 1, + [toIdTuple(bar2)], + expect.any(Object) + ); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 2, + [toIdTuple(dolly3)], + expect.any(Object) + ); + + expect(objects.map(toIdTuple)).toEqual([foo1, bar2, dolly3].map(toIdTuple)); + }); + + it('does not fetch duplicates of references', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + { + type: 'baz', + id: '4', + name: 'baz-4', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + ], + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + }); + const baz4 = createObject({ + type: 'baz', + id: '4', + }); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [dolly3, baz4], + }); + + await collectExportedObjects({ + objects: [foo1, bar2], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [dolly3, baz4].map(toIdTuple), + expect.any(Object) + ); + }); + + it('fetch references for additional objects returned by the export transform', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'baz', + id: '4', + name: 'baz-4', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [ + { + type: 'dolly', + id: '3', + name: 'dolly-3', + }, + ], + }); + + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, bar2]); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [], + }); + + await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [ + { type: 'baz', id: '4' }, + { type: 'dolly', id: '3' }, + ], + expect.any(Object) + ); + }); + + it('fetch references for additional objects returned by the export transform of nested references', async () => { + const foo1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + type: 'bar', + id: '2', + name: 'bar-2', + }, + ], + }); + const bar2 = createObject({ + type: 'bar', + id: '2', + references: [], + }); + const dolly3 = createObject({ + type: 'dolly', + id: '3', + references: [ + { + type: 'baz', + id: '4', + name: 'baz-4', + }, + ], + }); + const baz4 = createObject({ + type: 'baz', + id: '4', + }); + + // first call for foo-1 + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects]); + // second call for bar-2 + applyExportTransformsMock.mockImplementationOnce(({ objects }) => [...objects, dolly3]); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [bar2], + }); + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [baz4], + }); + + await collectExportedObjects({ + objects: [foo1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: true, + }); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 1, + [toIdTuple(bar2)], + expect.any(Object) + ); + expect(savedObjectsClient.bulkGet).toHaveBeenNthCalledWith( + 2, + [toIdTuple(baz4)], + expect.any(Object) + ); + }); + }); + + describe('when `includeReferences` is `false`', () => { + it('does not fetch the object references', async () => { + const obj1 = createObject({ + type: 'foo', + id: '1', + references: [ + { + id: '2', + type: 'bar', + name: 'bar-2', + }, + ], + }); + + const { objects, missingRefs } = await collectExportedObjects({ + objects: [obj1], + savedObjectsClient, + request, + exportTransforms: {}, + includeReferences: false, + }); + + expect(missingRefs).toHaveLength(0); + expect(objects.map(toIdTuple)).toEqual([ + { + type: 'foo', + id: '1', + }, + ]); + + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/core/server/saved_objects/export/collect_exported_objects.ts b/src/core/server/saved_objects/export/collect_exported_objects.ts new file mode 100644 index 00000000000000..d45782a83c2844 --- /dev/null +++ b/src/core/server/saved_objects/export/collect_exported_objects.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 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 { SavedObject } from '../../../types'; +import type { KibanaRequest } from '../../http'; +import { SavedObjectsClientContract } from '../types'; +import type { SavedObjectsExportTransform } from './types'; +import { applyExportTransforms } from './apply_export_transforms'; + +interface CollectExportedObjectOptions { + savedObjectsClient: SavedObjectsClientContract; + objects: SavedObject[]; + /** flag to also include all related saved objects in the export stream. */ + includeReferences?: boolean; + /** optional namespace to override the namespace used by the savedObjectsClient. */ + namespace?: string; + /** The http request initiating the export. */ + request: KibanaRequest; + /** export transform per type */ + exportTransforms: Record; +} + +interface CollectExportedObjectResult { + objects: SavedObject[]; + missingRefs: CollectedReference[]; +} + +export const collectExportedObjects = async ({ + objects, + includeReferences = true, + namespace, + request, + exportTransforms, + savedObjectsClient, +}: CollectExportedObjectOptions): Promise => { + const collectedObjects: SavedObject[] = []; + const collectedMissingRefs: CollectedReference[] = []; + const alreadyProcessed: Set = new Set(); + + let currentObjects = objects; + do { + const transformed = ( + await applyExportTransforms({ + request, + objects: currentObjects, + transforms: exportTransforms, + }) + ).filter((object) => !alreadyProcessed.has(objKey(object))); + + transformed.forEach((obj) => alreadyProcessed.add(objKey(obj))); + collectedObjects.push(...transformed); + + if (includeReferences) { + const references = collectReferences(transformed, alreadyProcessed); + if (references.length) { + const { objects: fetchedObjects, missingRefs } = await fetchReferences({ + references, + namespace, + client: savedObjectsClient, + }); + collectedMissingRefs.push(...missingRefs); + currentObjects = fetchedObjects; + } else { + currentObjects = []; + } + } else { + currentObjects = []; + } + } while (includeReferences && currentObjects.length); + + return { + objects: collectedObjects, + missingRefs: collectedMissingRefs, + }; +}; + +const objKey = (obj: { type: string; id: string }) => `${obj.type}:${obj.id}`; + +type ObjectKey = string; + +interface CollectedReference { + id: string; + type: string; +} + +const collectReferences = ( + objects: SavedObject[], + alreadyProcessed: Set +): CollectedReference[] => { + const references: Map = new Map(); + objects.forEach((obj) => { + obj.references?.forEach((ref) => { + const refKey = objKey(ref); + if (!alreadyProcessed.has(refKey)) { + references.set(refKey, { type: ref.type, id: ref.id }); + } + }); + }); + return [...references.values()]; +}; + +interface FetchReferencesResult { + objects: SavedObject[]; + missingRefs: CollectedReference[]; +} + +const fetchReferences = async ({ + references, + client, + namespace, +}: { + references: CollectedReference[]; + client: SavedObjectsClientContract; + namespace?: string; +}): Promise => { + const { saved_objects: savedObjects } = await client.bulkGet(references, { namespace }); + return { + objects: savedObjects.filter((obj) => !obj.error), + missingRefs: savedObjects + .filter((obj) => obj.error) + .map((obj) => ({ type: obj.type, id: obj.id })), + }; +}; diff --git a/src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts b/src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts deleted file mode 100644 index a47c629f9066b1..00000000000000 --- a/src/core/server/saved_objects/export/fetch_nested_dependencies.test.ts +++ /dev/null @@ -1,606 +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 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 { SavedObject } from '../types'; -import { savedObjectsClientMock } from '../../mocks'; -import { getObjectReferencesToFetch, fetchNestedDependencies } from './fetch_nested_dependencies'; -import { SavedObjectsErrorHelpers } from '..'; - -describe('getObjectReferencesToFetch()', () => { - test('works with no saved objects', () => { - const map = new Map(); - const result = getObjectReferencesToFetch(map); - expect(result).toEqual([]); - }); - - test('excludes already fetched objects', () => { - const map = new Map(); - map.set('index-pattern:1', { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }); - map.set('visualization:2', { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }); - const result = getObjectReferencesToFetch(map); - expect(result).toEqual([]); - }); - - test('returns objects that are missing', () => { - const map = new Map(); - map.set('visualization:2', { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }); - const result = getObjectReferencesToFetch(map); - expect(result).toMatchInlineSnapshot(` - Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ] - `); - }); - - test('does not fail on circular dependencies', () => { - const map = new Map(); - map.set('index-pattern:1', { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'visualization', - id: '2', - }, - ], - }); - map.set('visualization:2', { - id: '2', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }); - const result = getObjectReferencesToFetch(map); - expect(result).toEqual([]); - }); -}); - -describe('injectNestedDependencies', () => { - const savedObjectsClient = savedObjectsClientMock.create(); - - afterEach(() => { - jest.resetAllMocks(); - }); - - test(`doesn't fetch when no dependencies are missing`, async () => { - const savedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ]; - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - }); - - test(`doesn't fetch references that are already fetched`, async () => { - const savedObjects = [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - ], - } - `); - }); - - test('fetches dependencies at least one level deep', async () => { - const savedObjects = [ - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - - test('fetches dependencies multiple levels deep', async () => { - const savedObjects = [ - { - id: '5', - type: 'dashboard', - attributes: {}, - references: [ - { - name: 'panel_0', - type: 'visualization', - id: '4', - }, - { - name: 'panel_1', - type: 'visualization', - id: '3', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '4', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'search', - id: '2', - }, - ], - }, - { - id: '3', - type: 'visualization', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ], - }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "5", - "references": Array [ - Object { - "id": "4", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "3", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", - }, - Object { - "attributes": Object {}, - "id": "4", - "references": Array [ - Object { - "id": "2", - "name": "ref_0", - "type": "search", - }, - ], - "type": "visualization", - }, - Object { - "attributes": Object {}, - "id": "3", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "visualization", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "id": "4", - "type": "visualization", - }, - Object { - "id": "3", - "type": "visualization", - }, - ], - Object { - "namespace": undefined, - }, - ], - Array [ - Array [ - Object { - "id": "2", - "type": "search", - }, - Object { - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); - - test('returns list of missing references', async () => { - const savedObjects = [ - { - id: '1', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - { - name: 'ref_1', - type: 'index-pattern', - id: '2', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - error: SavedObjectsErrorHelpers.createGenericNotFoundError('index-pattern', '1').output - .payload, - attributes: {}, - references: [], - }, - { - id: '2', - type: 'index-pattern', - attributes: {}, - references: [], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - Object { - "id": "2", - "name": "ref_1", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [], - "type": "index-pattern", - }, - ], - } - `); - }); - - test('does not fail on circular dependencies', async () => { - const savedObjects = [ - { - id: '2', - type: 'search', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'index-pattern', - id: '1', - }, - ], - }, - ]; - savedObjectsClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: 'index-pattern', - attributes: {}, - references: [ - { - name: 'ref_0', - type: 'search', - id: '2', - }, - ], - }, - ], - }); - const result = await fetchNestedDependencies(savedObjects, savedObjectsClient); - expect(result).toMatchInlineSnapshot(` - Object { - "missingRefs": Array [], - "objects": Array [ - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [ - Object { - "id": "2", - "name": "ref_0", - "type": "search", - }, - ], - "type": "index-pattern", - }, - ], - } - `); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "id": "1", - "type": "index-pattern", - }, - ], - Object { - "namespace": undefined, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - } - `); - }); -}); diff --git a/src/core/server/saved_objects/export/fetch_nested_dependencies.ts b/src/core/server/saved_objects/export/fetch_nested_dependencies.ts deleted file mode 100644 index 778c01804b8939..00000000000000 --- a/src/core/server/saved_objects/export/fetch_nested_dependencies.ts +++ /dev/null @@ -1,50 +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 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 { SavedObject, SavedObjectsClientContract } from '../types'; - -export function getObjectReferencesToFetch(savedObjectsMap: Map) { - const objectsToFetch = new Map(); - for (const savedObject of savedObjectsMap.values()) { - for (const ref of savedObject.references || []) { - if (!savedObjectsMap.has(objKey(ref))) { - objectsToFetch.set(objKey(ref), { type: ref.type, id: ref.id }); - } - } - } - return [...objectsToFetch.values()]; -} - -export async function fetchNestedDependencies( - savedObjects: SavedObject[], - savedObjectsClient: SavedObjectsClientContract, - namespace?: string -) { - const savedObjectsMap = new Map(); - for (const savedObject of savedObjects) { - savedObjectsMap.set(objKey(savedObject), savedObject); - } - let objectsToFetch = getObjectReferencesToFetch(savedObjectsMap); - while (objectsToFetch.length > 0) { - const bulkGetResponse = await savedObjectsClient.bulkGet(objectsToFetch, { namespace }); - // Push to array result - for (const savedObject of bulkGetResponse.saved_objects) { - savedObjectsMap.set(objKey(savedObject), savedObject); - } - objectsToFetch = getObjectReferencesToFetch(savedObjectsMap); - } - const allObjects = [...savedObjectsMap.values()]; - return { - objects: allObjects.filter((obj) => !obj.error), - missingRefs: allObjects - .filter((obj) => !!obj.error) - .map((obj) => ({ type: obj.type, id: obj.id })), - }; -} - -const objKey = (obj: { type: string; id: string }) => `${obj.type}:${obj.id}`; diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.ts b/src/core/server/saved_objects/export/saved_objects_exporter.ts index 8cd6934bf1af9c..9d56bb4872a6dc 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts @@ -12,7 +12,6 @@ import { Logger } from '../../logging'; import { SavedObject, SavedObjectsClientContract } from '../types'; import { SavedObjectsFindResult } from '../service'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; -import { fetchNestedDependencies } from './fetch_nested_dependencies'; import { sortObjects } from './sort_objects'; import { SavedObjectsExportResultDetails, @@ -22,7 +21,7 @@ import { SavedObjectsExportTransform, } from './types'; import { SavedObjectsExportError } from './errors'; -import { applyExportTransforms } from './apply_export_transforms'; +import { collectExportedObjects } from './collect_exported_objects'; import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils'; /** @@ -118,28 +117,21 @@ export class SavedObjectsExporter { }: SavedObjectExportBaseOptions ) { this.#log.debug(`Processing [${savedObjects.length}] saved objects.`); - let exportedObjects: Array>; - let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; - savedObjects = await applyExportTransforms({ - request, + const { + objects: collectedObjects, + missingRefs: missingReferences, + } = await collectExportedObjects({ objects: savedObjects, - transforms: this.#exportTransforms, - sortFunction, + includeReferences: includeReferencesDeep, + namespace, + request, + exportTransforms: this.#exportTransforms, + savedObjectsClient: this.#savedObjectsClient, }); - if (includeReferencesDeep) { - this.#log.debug(`Fetching saved objects references.`); - const fetchResult = await fetchNestedDependencies( - savedObjects, - this.#savedObjectsClient, - namespace - ); - exportedObjects = sortObjects(fetchResult.objects); - missingReferences = fetchResult.missingRefs; - } else { - exportedObjects = sortObjects(savedObjects); - } + // sort with the provided sort function then with the default export sorting + const exportedObjects = sortObjects(collectedObjects.sort(sortFunction)); // redact attributes that should not be exported const redactedObjects = includeNamespaces diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json new file mode 100644 index 00000000000000..caac89461b9ef0 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/data.json @@ -0,0 +1,87 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-transform:type_1-obj_1", + "source": { + "test-export-transform": { + "title": "test_1-obj_1", + "enabled": true + }, + "type": "test-export-transform", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z", + "references": [ + { + "type": "test-export-transform", + "id": "type_1-obj_2", + "name": "ref-1" + }, + { + "type": "test-export-add", + "id": "type_2-obj_1", + "name": "ref-2" + } + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-transform:type_1-obj_2", + "source": { + "test-export-transform": { + "title": "test_1-obj_2", + "enabled": true + }, + "type": "test-export-transform", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-add:type_2-obj_1", + "source": { + "test-export-add": { + "title": "test_2-obj_1" + }, + "type": "test-export-add", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "test-export-add-dep:type_dep-obj_1", + "source": { + "test-export-add-dep": { + "title": "type_dep-obj_1" + }, + "type": "test-export-add-dep", + "migrationVersion": {}, + "updated_at": "2018-12-21T00:43:07.096Z", + "references": [ + { + "type": "test-export-add", + "id": "type_2-obj_1" + } + ] + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json new file mode 100644 index 00000000000000..43b851e817fa81 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json @@ -0,0 +1,499 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "test-export-transform": { + "properties": { + "title": { "type": "text" }, + "enabled": { "type": "boolean" } + } + }, + "test-export-add": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-add-dep": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-transform-error": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-invalid-transform": { + "properties": { + "title": { "type": "text" } + } + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "bounds": { + "dynamic": false, + "properties": {} + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + } + } + } + } +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts b/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts index 87bf5e0584a7d7..2b845cb6327b88 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/export_transform.ts @@ -19,122 +19,169 @@ export default function ({ getService }: PluginFunctionalProviderContext) { const esArchiver = getService('esArchiver'); describe('export transforms', () => { - before(async () => { - await esArchiver.load( - '../functional/fixtures/es_archiver/saved_objects_management/export_transform' - ); - }); + describe('root objects export transforms', () => { + before(async () => { + await esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/export_transform' + ); + }); - after(async () => { - await esArchiver.unload( - '../functional/fixtures/es_archiver/saved_objects_management/export_transform' - ); - }); + after(async () => { + await esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/export_transform' + ); + }); - it('allows to mutate the objects during an export', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-transform'], - excludeExportDetails: true, - }) - .expect(200) - .then((resp) => { - const objects = parseNdJson(resp.text); - expect(objects.map((obj) => ({ id: obj.id, enabled: obj.attributes.enabled }))).to.eql([ - { - id: 'type_1-obj_1', - enabled: false, - }, - { - id: 'type_1-obj_2', - enabled: false, - }, - ]); - }); - }); + it('allows to mutate the objects during an export', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-transform'], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text); + expect(objects.map((obj) => ({ id: obj.id, enabled: obj.attributes.enabled }))).to.eql([ + { + id: 'type_1-obj_1', + enabled: false, + }, + { + id: 'type_1-obj_2', + enabled: false, + }, + ]); + }); + }); - it('allows to add additional objects to an export', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - objects: [ - { - type: 'test-export-add', - id: 'type_2-obj_1', - }, - ], - excludeExportDetails: true, - }) - .expect(200) - .then((resp) => { - const objects = parseNdJson(resp.text); - expect(objects.map((obj) => obj.id)).to.eql(['type_2-obj_1', 'type_dep-obj_1']); - }); - }); + it('allows to add additional objects to an export', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + objects: [ + { + type: 'test-export-add', + id: 'type_2-obj_1', + }, + ], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text); + expect(objects.map((obj) => obj.id)).to.eql(['type_2-obj_1', 'type_dep-obj_1']); + }); + }); - it('allows to add additional objects to an export when exporting by type', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-add'], - excludeExportDetails: true, - }) - .expect(200) - .then((resp) => { - const objects = parseNdJson(resp.text); - expect(objects.map((obj) => obj.id)).to.eql([ - 'type_2-obj_1', - 'type_2-obj_2', - 'type_dep-obj_1', - 'type_dep-obj_2', - ]); - }); - }); + it('allows to add additional objects to an export when exporting by type', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-add'], + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text); + expect(objects.map((obj) => obj.id)).to.eql([ + 'type_2-obj_1', + 'type_2-obj_2', + 'type_dep-obj_1', + 'type_dep-obj_2', + ]); + }); + }); + + it('returns a 400 when the type causes a transform error', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-transform-error'], + excludeExportDetails: true, + }) + .expect(400) + .then((resp) => { + const { attributes, ...error } = resp.body; + expect(error).to.eql({ + error: 'Bad Request', + message: 'Error transforming objects to export', + statusCode: 400, + }); + expect(attributes.cause).to.eql('Error during transform'); + expect(attributes.objects.map((obj: any) => obj.id)).to.eql(['type_4-obj_1']); + }); + }); - it('returns a 400 when the type causes a transform error', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-transform-error'], - excludeExportDetails: true, - }) - .expect(400) - .then((resp) => { - const { attributes, ...error } = resp.body; - expect(error).to.eql({ - error: 'Bad Request', - message: 'Error transforming objects to export', - statusCode: 400, + it('returns a 400 when the type causes an invalid transform', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-export-invalid-transform'], + excludeExportDetails: true, + }) + .expect(400) + .then((resp) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: 'Invalid transform performed on objects to export', + statusCode: 400, + attributes: { + objectKeys: ['test-export-invalid-transform|type_3-obj_1'], + }, + }); }); - expect(attributes.cause).to.eql('Error during transform'); - expect(attributes.objects.map((obj: any) => obj.id)).to.eql(['type_4-obj_1']); - }); + }); }); - it('returns a 400 when the type causes an invalid transform', async () => { - await supertest - .post('/api/saved_objects/_export') - .set('kbn-xsrf', 'true') - .send({ - type: ['test-export-invalid-transform'], - excludeExportDetails: true, - }) - .expect(400) - .then((resp) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: 'Invalid transform performed on objects to export', - statusCode: 400, - attributes: { - objectKeys: ['test-export-invalid-transform|type_3-obj_1'], - }, + describe('FOO nested export transforms', () => { + before(async () => { + await esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/nested_export_transform' + ); + }); + + after(async () => { + await esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/nested_export_transform' + ); + }); + + it('execute export transforms for reference objects', async () => { + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + objects: [ + { + type: 'test-export-transform', + id: 'type_1-obj_1', + }, + ], + includeReferencesDeep: true, + excludeExportDetails: true, + }) + .expect(200) + .then((resp) => { + const objects = parseNdJson(resp.text).sort((obj1, obj2) => + obj1.id.localeCompare(obj2.id) + ); + expect(objects.map((obj) => obj.id)).to.eql([ + 'type_1-obj_1', + 'type_1-obj_2', + 'type_2-obj_1', + 'type_dep-obj_1', + ]); + + expect(objects[0].attributes.enabled).to.eql(false); + expect(objects[1].attributes.enabled).to.eql(false); }); - }); + }); }); }); } From 8f83090d74d0254a870ce3540999a7e40ecfea91 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Fri, 4 Jun 2021 08:48:35 -0400 Subject: [PATCH 64/77] [Uptime] Show URL and metrics on sidebar and waterfall item tooltips (#99985) * Add URL to metrics tooltip. * Add screenreader label for URL container. * Add metrics to URL sidebar tooltip. * Rename vars. * Delete unnecessary code. * Undo rename. * Extract component to dedicated file, add tests. * Fix error in test. * Add offset index to heading of waterfall chart tooltip. * Format the waterfall tool tip header. * Add horizontal rule and bold text for waterfall tooltip. * Extract inline helper function to module-level for reuse. * Reuse waterfall tooltip style. * Style reusable tooltip content. * Adapt existing chart tooltip to use tooltip content component for better consistency. * Delete test code. * Style EUI tooltip arrow. * Revert whitespace change. * Delete obsolete test. * Implement and use common tooltip heading formatter function. * Add tests for new formatter function. * Fix a typo. * Add a comment explaining a style hack. * Add optional chaining to avoid breaking a test. * Revert previous change, use RTL wrapper, rename describe block. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../waterfall/data_formatting.test.ts | 11 +++ .../step_detail/waterfall/data_formatting.ts | 3 + .../components/middle_truncated_text.tsx | 8 +- .../synthetics/waterfall/components/styles.ts | 3 + .../components/waterfall_bar_chart.tsx | 46 +++++----- .../waterfall_tooltip_content.test.tsx | 84 +++++++++++++++++++ .../components/waterfall_tooltip_content.tsx | 46 ++++++++++ 7 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts index ebb6eb6bdc989c..229933c2f06429 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.test.ts @@ -7,6 +7,7 @@ import moment from 'moment'; import { colourPalette, + formatTooltipHeading, getConnectingTime, getSeriesAndDomain, getSidebarItems, @@ -729,3 +730,13 @@ describe('getSidebarItems', () => { expect(actual[0].offsetIndex).toBe(1); }); }); + +describe('formatTooltipHeading', () => { + it('puts index and URL text together', () => { + expect(formatTooltipHeading(1, 'http://www.elastic.co/')).toEqual('1. http://www.elastic.co/'); + }); + + it('returns only the text if `index` is NaN', () => { + expect(formatTooltipHeading(NaN, 'http://www.elastic.co/')).toEqual('http://www.elastic.co/'); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts index f497bf1ea7b354..0f0ce01d250998 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/waterfall/data_formatting.ts @@ -450,3 +450,6 @@ const MIME_TYPE_PALETTE = buildMimeTypePalette(); type ColourPalette = TimingColourPalette & MimeTypeColourPalette; export const colourPalette: ColourPalette = { ...TIMING_PALETTE, ...MIME_TYPE_PALETTE }; + +export const formatTooltipHeading = (index: number, fullText: string): string => + isNaN(index) ? fullText : `${index}. ${fullText}`; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx index a661d60400f97d..956f4b19c66263 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/middle_truncated_text.tsx @@ -8,17 +8,19 @@ import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { + EuiButtonEmpty, EuiScreenReaderOnly, EuiToolTip, - EuiButtonEmpty, EuiLink, EuiText, EuiIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { WaterfallTooltipContent } from './waterfall_tooltip_content'; import { WaterfallTooltipResponsiveMaxWidth } from './styles'; import { FIXED_AXIS_HEIGHT } from './constants'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; +import { formatTooltipHeading } from '../../step_detail/waterfall/data_formatting'; interface Props { index: number; @@ -116,7 +118,9 @@ export const MiddleTruncatedText = ({ + } data-test-subj="middleTruncatedTextToolTip" delay="long" position="top" diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts index 8e3033037766a2..f8de61f9d86903 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/styles.ts @@ -153,6 +153,9 @@ export const WaterfallChartTooltip = euiStyled(WaterfallTooltipResponsiveMaxWidt border-radius: ${(props) => props.theme.eui.euiBorderRadius}; color: ${(props) => props.theme.eui.euiColorLightestShade}; padding: ${(props) => props.theme.eui.paddingSizes.s}; + .euiToolTip__arrow { + background-color: ${(props) => props.theme.eui.euiColorDarkestShade}; + } `; export const NetworkRequestsTotalStyle = euiStyled(EuiText)` diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx index 19a828aa097b6f..8723dd744132ae 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_bar_chart.tsx @@ -18,11 +18,12 @@ import { TickFormatter, TooltipInfo, } from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { BAR_HEIGHT } from './constants'; import { useChartTheme } from '../../../../../hooks/use_chart_theme'; import { WaterfallChartChartContainer, WaterfallChartTooltip } from './styles'; import { useWaterfallContext, WaterfallData } from '..'; +import { WaterfallTooltipContent } from './waterfall_tooltip_content'; +import { formatTooltipHeading } from '../../step_detail/waterfall/data_formatting'; const getChartHeight = (data: WaterfallData): number => { // We get the last item x(number of bars) and adds 1 to cater for 0 index @@ -32,23 +33,25 @@ const getChartHeight = (data: WaterfallData): number => { }; const Tooltip = (tooltipInfo: TooltipInfo) => { - const { data, renderTooltipItem } = useWaterfallContext(); - const relevantItems = data.filter((item) => { - return ( - item.x === tooltipInfo.header?.value && item.config.showTooltip && item.config.tooltipProps - ); - }); - return relevantItems.length ? ( - - - {relevantItems.map((item, index) => { - return ( - {renderTooltipItem(item.config.tooltipProps)} - ); - })} - - - ) : null; + const { data, sidebarItems } = useWaterfallContext(); + return useMemo(() => { + const sidebarItem = sidebarItems?.find((item) => item.index === tooltipInfo.header?.value); + const relevantItems = data.filter((item) => { + return ( + item.x === tooltipInfo.header?.value && item.config.showTooltip && item.config.tooltipProps + ); + }); + return relevantItems.length ? ( + + {sidebarItem && ( + + )} + + ) : null; + }, [data, sidebarItems, tooltipInfo.header?.value]); }; interface Props { @@ -82,7 +85,12 @@ export const WaterfallBarChart = ({ ({ + useWaterfallContext: jest.fn().mockReturnValue({ + data: [ + { + x: 0, + config: { + url: 'https://www.elastic.co', + tooltipProps: { + colour: '#000000', + value: 'test-val', + }, + showTooltip: true, + }, + }, + { + x: 0, + config: { + url: 'https://www.elastic.co/with/missing/tooltip.props', + showTooltip: true, + }, + }, + { + x: 1, + config: { + url: 'https://www.elastic.co/someresource.path', + tooltipProps: { + colour: '#010000', + value: 'test-val-missing', + }, + showTooltip: true, + }, + }, + ], + renderTooltipItem: (props: any) => ( +
+
{props.colour}
+
{props.value}
+
+ ), + sidebarItems: [ + { + isHighlighted: true, + index: 0, + offsetIndex: 1, + url: 'https://www.elastic.co', + status: 200, + method: 'GET', + }, + ], + }), +})); + +describe('WaterfallTooltipContent', () => { + it('renders tooltip', () => { + const { getByText, queryByText } = render( + + ); + expect(getByText('#000000')).toBeInTheDocument(); + expect(getByText('test-val')).toBeInTheDocument(); + expect(getByText('1. https://www.elastic.co')).toBeInTheDocument(); + expect(queryByText('#010000')).toBeNull(); + expect(queryByText('test-val-missing')).toBeNull(); + }); + + it(`doesn't render metric if tooltip props missing`, () => { + const { getAllByLabelText, getByText } = render( + + ); + const metricElements = getAllByLabelText('tooltip item'); + expect(metricElements).toHaveLength(1); + expect(getByText('test-val')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx new file mode 100644 index 00000000000000..21b3bf72d22178 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_tooltip_content.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiText } from '@elastic/eui'; +import { useWaterfallContext } from '../context/waterfall_chart'; +import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; + +interface Props { + text: string; + url: string; +} + +const StyledText = euiStyled(EuiText)` + font-weight: bold; +`; + +const StyledHorizontalRule = euiStyled(EuiHorizontalRule)` + background-color: ${(props) => props.theme.eui.euiColorDarkShade}; +`; + +export const WaterfallTooltipContent: React.FC = ({ text, url }) => { + const { data, renderTooltipItem, sidebarItems } = useWaterfallContext(); + + const tooltipMetrics = data.filter( + (datum) => + datum.x === sidebarItems?.find((sidebarItem) => sidebarItem.url === url)?.index && + datum.config.tooltipProps && + datum.config.showTooltip + ); + return ( + <> + {text} + + + {tooltipMetrics.map((item, idx) => ( + {renderTooltipItem(item.config.tooltipProps)} + ))} + + + ); +}; From 93df9a32a49d13ebbdc53de7ec9e99e3c2c7c1da Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 4 Jun 2021 08:00:41 -0600 Subject: [PATCH 65/77] [Maps] embeddable migrations (#101070) * [Maps] embeddable migrations * review feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../maps/server/embeddable_migrations.test.ts | 21 ++++ .../maps/server/embeddable_migrations.ts | 26 ++++ x-pack/plugins/maps/server/plugin.ts | 8 ++ .../plugins/maps/server/saved_objects/map.ts | 4 +- .../maps/server/saved_objects/migrations.js | 107 ----------------- .../saved_objects/saved_object_migrations.js | 112 ++++++++++++++++++ .../api_integration/apis/maps/migrations.js | 94 ++++++++++----- .../es_archives/maps/kibana/data.json | 28 +++++ 8 files changed, 259 insertions(+), 141 deletions(-) create mode 100644 x-pack/plugins/maps/server/embeddable_migrations.test.ts create mode 100644 x-pack/plugins/maps/server/embeddable_migrations.ts delete mode 100644 x-pack/plugins/maps/server/saved_objects/migrations.js create mode 100644 x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js diff --git a/x-pack/plugins/maps/server/embeddable_migrations.test.ts b/x-pack/plugins/maps/server/embeddable_migrations.test.ts new file mode 100644 index 00000000000000..306f716d5171d3 --- /dev/null +++ b/x-pack/plugins/maps/server/embeddable_migrations.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import semverGte from 'semver/functions/gte'; +import { embeddableMigrations } from './embeddable_migrations'; +// @ts-ignore +import { savedObjectMigrations } from './saved_objects/saved_object_migrations'; + +describe('saved object migrations and embeddable migrations', () => { + test('should have same versions registered (>7.12)', () => { + const savedObjectMigrationVersions = Object.keys(savedObjectMigrations).filter((version) => { + return semverGte(version, '7.13.0'); + }); + const embeddableMigrationVersions = Object.keys(embeddableMigrations); + expect(savedObjectMigrationVersions.sort()).toEqual(embeddableMigrationVersions.sort()); + }); +}); diff --git a/x-pack/plugins/maps/server/embeddable_migrations.ts b/x-pack/plugins/maps/server/embeddable_migrations.ts new file mode 100644 index 00000000000000..4bf39dc1f999c5 --- /dev/null +++ b/x-pack/plugins/maps/server/embeddable_migrations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SerializableState } from '../../../../src/plugins/kibana_utils/common'; +import { MapSavedObjectAttributes } from '../common/map_saved_object_type'; +import { moveAttribution } from '../common/migrations/move_attribution'; + +/* + * Embeddables such as Maps, Lens, and Visualize can be embedded by value or by reference on a dashboard. + * To ensure that any migrations (>7.12) are run correctly in both cases, + * the migration function must be registered as both a saved object migration and an embeddable migration + + * This is the embeddable migration registry. + */ +export const embeddableMigrations = { + '7.14.0': (state: SerializableState) => { + return { + ...state, + attributes: moveAttribution(state as { attributes: MapSavedObjectAttributes }), + } as SerializableState; + }, +}; diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index f0c8a051f8f796..c7532979320378 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -37,6 +37,8 @@ import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; import { MapsEmsPluginSetup } from '../../../../src/plugins/maps_ems/server'; import { EMSSettings } from '../common/ems_settings'; import { PluginStart as DataPluginStart } from '../../../../src/plugins/data/server'; +import { EmbeddableSetup } from '../../../../src/plugins/embeddable/server'; +import { embeddableMigrations } from './embeddable_migrations'; interface SetupDeps { features: FeaturesPluginSetupContract; @@ -44,6 +46,7 @@ interface SetupDeps { home: HomeServerPluginSetup; licensing: LicensingPluginSetup; mapsEms: MapsEmsPluginSetup; + embeddable: EmbeddableSetup; } export interface StartDeps { @@ -214,6 +217,11 @@ export class MapsPlugin implements Plugin { core.savedObjects.registerType(mapSavedObjects); registerMapsUsageCollector(usageCollection, currentConfig); + plugins.embeddable.registerEmbeddableFactory({ + id: MAP_SAVED_OBJECT_TYPE, + migrations: embeddableMigrations, + }); + return { config: config$, }; diff --git a/x-pack/plugins/maps/server/saved_objects/map.ts b/x-pack/plugins/maps/server/saved_objects/map.ts index f4091db66d3da7..78f70e27b2b7bf 100644 --- a/x-pack/plugins/maps/server/saved_objects/map.ts +++ b/x-pack/plugins/maps/server/saved_objects/map.ts @@ -8,7 +8,7 @@ import { SavedObjectsType } from 'src/core/server'; import { APP_ICON, getExistingMapPath } from '../../common/constants'; // @ts-ignore -import { migrations } from './migrations'; +import { savedObjectMigrations } from './saved_object_migrations'; export const mapSavedObjects: SavedObjectsType = { name: 'map', @@ -39,5 +39,5 @@ export const mapSavedObjects: SavedObjectsType = { }; }, }, - migrations: migrations.map, + migrations: savedObjectMigrations, }; diff --git a/x-pack/plugins/maps/server/saved_objects/migrations.js b/x-pack/plugins/maps/server/saved_objects/migrations.js deleted file mode 100644 index d10e22722970a8..00000000000000 --- a/x-pack/plugins/maps/server/saved_objects/migrations.js +++ /dev/null @@ -1,107 +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 { extractReferences } from '../../common/migrations/references'; -import { emsRasterTileToEmsVectorTile } from '../../common/migrations/ems_raster_tile_to_ems_vector_tile'; -import { topHitsTimeToSort } from '../../common/migrations/top_hits_time_to_sort'; -import { moveApplyGlobalQueryToSources } from '../../common/migrations/move_apply_global_query'; -import { addFieldMetaOptions } from '../../common/migrations/add_field_meta_options'; -import { migrateSymbolStyleDescriptor } from '../../common/migrations/migrate_symbol_style_descriptor'; -import { migrateUseTopHitsToScalingType } from '../../common/migrations/scaling_type'; -import { migrateJoinAggKey } from '../../common/migrations/join_agg_key'; -import { removeBoundsFromSavedObject } from '../../common/migrations/remove_bounds'; -import { setDefaultAutoFitToBounds } from '../../common/migrations/set_default_auto_fit_to_bounds'; -import { addTypeToTermJoin } from '../../common/migrations/add_type_to_termjoin'; -import { moveAttribution } from '../../common/migrations/move_attribution'; - -export const migrations = { - map: { - '7.2.0': (doc) => { - const { attributes, references } = extractReferences(doc); - - return { - ...doc, - attributes, - references, - }; - }, - '7.4.0': (doc) => { - const attributes = emsRasterTileToEmsVectorTile(doc); - - return { - ...doc, - attributes, - }; - }, - '7.5.0': (doc) => { - const attributes = topHitsTimeToSort(doc); - - return { - ...doc, - attributes, - }; - }, - '7.6.0': (doc) => { - const attributesPhase1 = moveApplyGlobalQueryToSources(doc); - const attributesPhase2 = addFieldMetaOptions({ attributes: attributesPhase1 }); - - return { - ...doc, - attributes: attributesPhase2, - }; - }, - '7.7.0': (doc) => { - const attributesPhase1 = migrateSymbolStyleDescriptor(doc); - const attributesPhase2 = migrateUseTopHitsToScalingType({ attributes: attributesPhase1 }); - - return { - ...doc, - attributes: attributesPhase2, - }; - }, - '7.8.0': (doc) => { - const attributes = migrateJoinAggKey(doc); - - return { - ...doc, - attributes, - }; - }, - '7.9.0': (doc) => { - const attributes = removeBoundsFromSavedObject(doc); - - return { - ...doc, - attributes, - }; - }, - '7.10.0': (doc) => { - const attributes = setDefaultAutoFitToBounds(doc); - - return { - ...doc, - attributes, - }; - }, - '7.12.0': (doc) => { - const attributes = addTypeToTermJoin(doc); - - return { - ...doc, - attributes, - }; - }, - '7.14.0': (doc) => { - const attributes = moveAttribution(doc); - - return { - ...doc, - attributes, - }; - }, - }, -}; diff --git a/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js b/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js new file mode 100644 index 00000000000000..8866ebb6b3de39 --- /dev/null +++ b/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.js @@ -0,0 +1,112 @@ +/* + * 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 { extractReferences } from '../../common/migrations/references'; +import { emsRasterTileToEmsVectorTile } from '../../common/migrations/ems_raster_tile_to_ems_vector_tile'; +import { topHitsTimeToSort } from '../../common/migrations/top_hits_time_to_sort'; +import { moveApplyGlobalQueryToSources } from '../../common/migrations/move_apply_global_query'; +import { addFieldMetaOptions } from '../../common/migrations/add_field_meta_options'; +import { migrateSymbolStyleDescriptor } from '../../common/migrations/migrate_symbol_style_descriptor'; +import { migrateUseTopHitsToScalingType } from '../../common/migrations/scaling_type'; +import { migrateJoinAggKey } from '../../common/migrations/join_agg_key'; +import { removeBoundsFromSavedObject } from '../../common/migrations/remove_bounds'; +import { setDefaultAutoFitToBounds } from '../../common/migrations/set_default_auto_fit_to_bounds'; +import { addTypeToTermJoin } from '../../common/migrations/add_type_to_termjoin'; +import { moveAttribution } from '../../common/migrations/move_attribution'; + +/* + * Embeddables such as Maps, Lens, and Visualize can be embedded by value or by reference on a dashboard. + * To ensure that any migrations (>7.12) are run correctly in both cases, + * the migration function must be registered as both a saved object migration and an embeddable migration + + * This is the saved object migration registry. + */ +export const savedObjectMigrations = { + '7.2.0': (doc) => { + const { attributes, references } = extractReferences(doc); + + return { + ...doc, + attributes, + references, + }; + }, + '7.4.0': (doc) => { + const attributes = emsRasterTileToEmsVectorTile(doc); + + return { + ...doc, + attributes, + }; + }, + '7.5.0': (doc) => { + const attributes = topHitsTimeToSort(doc); + + return { + ...doc, + attributes, + }; + }, + '7.6.0': (doc) => { + const attributesPhase1 = moveApplyGlobalQueryToSources(doc); + const attributesPhase2 = addFieldMetaOptions({ attributes: attributesPhase1 }); + + return { + ...doc, + attributes: attributesPhase2, + }; + }, + '7.7.0': (doc) => { + const attributesPhase1 = migrateSymbolStyleDescriptor(doc); + const attributesPhase2 = migrateUseTopHitsToScalingType({ attributes: attributesPhase1 }); + + return { + ...doc, + attributes: attributesPhase2, + }; + }, + '7.8.0': (doc) => { + const attributes = migrateJoinAggKey(doc); + + return { + ...doc, + attributes, + }; + }, + '7.9.0': (doc) => { + const attributes = removeBoundsFromSavedObject(doc); + + return { + ...doc, + attributes, + }; + }, + '7.10.0': (doc) => { + const attributes = setDefaultAutoFitToBounds(doc); + + return { + ...doc, + attributes, + }; + }, + '7.12.0': (doc) => { + const attributes = addTypeToTermJoin(doc); + + return { + ...doc, + attributes, + }; + }, + '7.14.0': (doc) => { + const attributes = moveAttribution(doc); + + return { + ...doc, + attributes, + }; + }, +}; diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index 109579e867cb00..fe6e1c70356b0e 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -9,41 +9,71 @@ import expect from '@kbn/expect'; export default function ({ getService }) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('migrations', () => { - it('should apply saved object reference migration when importing map saved objects prior to 7.2.0', async () => { - const resp = await supertest - .post(`/api/saved_objects/map`) - .set('kbn-xsrf', 'kibana') - .send({ - attributes: { - title: '[Logs] Total Requests and Bytes', - layerListJSON: - '[{"id":"edh66","label":"Total Requests by Country","minZoom":0,"maxZoom":24,"alpha":0.5,"sourceDescriptor":{"type":"EMS_FILE","id":"world_countries"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"count of kibana_sample_data_logs:geo.src","name":"__kbnjoin__count_groupby_kibana_sample_data_logs.geo.src","origin":"join"},"color":"Greys"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"STATIC","options":{"size":10}}}},"type":"VECTOR","joins":[{"leftField":"iso2","right":{"id":"673ff994-fc75-4c67-909b-69fcb0e1060e","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","indexPatternTitle":"kibana_sample_data_logs","term":"geo.src"}}]},{"id":"gaxya","label":"Actual Requests","minZoom":9,"maxZoom":24,"alpha":1,"sourceDescriptor":{"id":"b7486535-171b-4d3b-bb2e-33c1a0a2854c","type":"ES_SEARCH","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","limit":2048,"filterByMapBounds":true,"tooltipProperties":["clientip","timestamp","host","request","response","machine.os","agent","bytes"]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"STATIC","options":{"color":"#2200ff"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":2}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"bytes","name":"bytes","origin":"source"},"minSize":1,"maxSize":23}}}},"type":"VECTOR"},{"id":"tfi3f","label":"Total Requests and Bytes","minZoom":0,"maxZoom":9,"alpha":1,"sourceDescriptor":{"type":"ES_GEO_GRID","resolution":"COARSE","id":"8aaa65b5-a4e9-448b-9560-c98cb1c5ac5b","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","requestType":"point","metrics":[{"type":"count"},{"type":"sum","field":"bytes"}]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"Count","name":"doc_count","origin":"source"},"color":"Blues"}},"lineColor":{"type":"STATIC","options":{"color":"#cccccc"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"sum of bytes","name":"sum_of_bytes","origin":"source"},"minSize":1,"maxSize":25}}}},"type":"VECTOR"}]', + describe('saved object migrations', () => { + it('should apply saved object reference migration when importing map saved objects prior to 7.2.0', async () => { + const resp = await supertest + .post(`/api/saved_objects/map`) + .set('kbn-xsrf', 'kibana') + .send({ + attributes: { + title: '[Logs] Total Requests and Bytes', + layerListJSON: + '[{"id":"edh66","label":"Total Requests by Country","minZoom":0,"maxZoom":24,"alpha":0.5,"sourceDescriptor":{"type":"EMS_FILE","id":"world_countries"},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"count of kibana_sample_data_logs:geo.src","name":"__kbnjoin__count_groupby_kibana_sample_data_logs.geo.src","origin":"join"},"color":"Greys"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"STATIC","options":{"size":10}}}},"type":"VECTOR","joins":[{"leftField":"iso2","right":{"id":"673ff994-fc75-4c67-909b-69fcb0e1060e","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","indexPatternTitle":"kibana_sample_data_logs","term":"geo.src"}}]},{"id":"gaxya","label":"Actual Requests","minZoom":9,"maxZoom":24,"alpha":1,"sourceDescriptor":{"id":"b7486535-171b-4d3b-bb2e-33c1a0a2854c","type":"ES_SEARCH","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","limit":2048,"filterByMapBounds":true,"tooltipProperties":["clientip","timestamp","host","request","response","machine.os","agent","bytes"]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"STATIC","options":{"color":"#2200ff"}},"lineColor":{"type":"STATIC","options":{"color":"#FFFFFF"}},"lineWidth":{"type":"STATIC","options":{"size":2}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"bytes","name":"bytes","origin":"source"},"minSize":1,"maxSize":23}}}},"type":"VECTOR"},{"id":"tfi3f","label":"Total Requests and Bytes","minZoom":0,"maxZoom":9,"alpha":1,"sourceDescriptor":{"type":"ES_GEO_GRID","resolution":"COARSE","id":"8aaa65b5-a4e9-448b-9560-c98cb1c5ac5b","indexPatternId":"90943e30-9a47-11e8-b64d-95841ca0b247","geoField":"geo.coordinates","requestType":"point","metrics":[{"type":"count"},{"type":"sum","field":"bytes"}]},"visible":true,"style":{"type":"VECTOR","properties":{"fillColor":{"type":"DYNAMIC","options":{"field":{"label":"Count","name":"doc_count","origin":"source"},"color":"Blues"}},"lineColor":{"type":"STATIC","options":{"color":"#cccccc"}},"lineWidth":{"type":"STATIC","options":{"size":1}},"iconSize":{"type":"DYNAMIC","options":{"field":{"label":"sum of bytes","name":"sum_of_bytes","origin":"source"},"minSize":1,"maxSize":25}}}},"type":"VECTOR"}]', + }, + migrationVersion: {}, + }) + .expect(200); + + expect(resp.body.references).to.eql([ + { + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'layer_0_join_0_index_pattern', + type: 'index-pattern', + }, + { + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'layer_1_source_index_pattern', + type: 'index-pattern', }, - migrationVersion: {}, - }) - .expect(200); - - expect(resp.body.references).to.eql([ - { - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'layer_0_join_0_index_pattern', - type: 'index-pattern', - }, - { - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'layer_1_source_index_pattern', - type: 'index-pattern', - }, - { - id: '90943e30-9a47-11e8-b64d-95841ca0b247', - name: 'layer_2_source_index_pattern', - type: 'index-pattern', - }, - ]); - expect(resp.body.migrationVersion).to.eql({ map: '7.14.0' }); - expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); + { + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + name: 'layer_2_source_index_pattern', + type: 'index-pattern', + }, + ]); + expect(resp.body.migrationVersion).to.eql({ map: '7.14.0' }); + expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); + }); + }); + + describe('embeddable migrations', () => { + before(async () => { + await esArchiver.loadIfNeeded('maps/kibana'); + }); + + after(async () => { + await esArchiver.unload('maps/kibana'); + }); + + it('should apply embeddable migrations', async () => { + const resp = await supertest + .get(`/api/saved_objects/dashboard/4beb0d80-c2ef-11eb-b0cb-bd162d969e6b`) + .set('kbn-xsrf', 'kibana') + .expect(200); + + let panels; + try { + panels = JSON.parse(resp.body.attributes.panelsJSON); + } catch (error) { + throw 'Unable to parse panelsJSON from dashboard saved object'; + } + expect(panels.length).to.be(1); + expect(panels[0].type).to.be('map'); + expect(panels[0].version).to.be('7.14.0'); + }); }); }); } diff --git a/x-pack/test/functional/es_archives/maps/kibana/data.json b/x-pack/test/functional/es_archives/maps/kibana/data.json index 4a879c20f19ab8..d0c4559d0a0a9f 100644 --- a/x-pack/test/functional/es_archives/maps/kibana/data.json +++ b/x-pack/test/functional/es_archives/maps/kibana/data.json @@ -1199,6 +1199,34 @@ } } +{ + "type": "doc", + "value": { + "id": "dashboard:4beb0d80-c2ef-11eb-b0cb-bd162d969e6b", + "index": ".kibana", + "source": { + "dashboard": { + "title" : "by value map", + "hits" : 0, + "description" : "", + "panelsJSON" : "[{\"version\":\"7.12.1-SNAPSHOT\",\"type\":\"map\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"cb82a9e3-2eb0-487f-9ade-0ffb921eb536\"},\"panelIndex\":\"cb82a9e3-2eb0-487f-9ade-0ffb921eb536\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"layerListJSON\":\"[]\",\"mapStateJSON\":\"{\\\"zoom\\\":1.75,\\\"center\\\":{\\\"lon\\\":0,\\\"lat\\\":19.94277},\\\"timeFilters\\\":{\\\"from\\\":\\\"now-15m\\\",\\\"to\\\":\\\"now\\\"},\\\"refreshConfig\\\":{\\\"isPaused\\\":true,\\\"interval\\\":0},\\\"query\\\":{\\\"query\\\":\\\"\\\",\\\"language\\\":\\\"kuery\\\"},\\\"filters\\\":[],\\\"settings\\\":{\\\"autoFitToDataBounds\\\":false,\\\"backgroundColor\\\":\\\"#ffffff\\\",\\\"disableInteractive\\\":false,\\\"disableTooltipControl\\\":false,\\\"hideToolbarOverlay\\\":false,\\\"hideLayerControl\\\":false,\\\"hideViewControl\\\":false,\\\"initialLocation\\\":\\\"LAST_SAVED_LOCATION\\\",\\\"fixedLocation\\\":{\\\"lat\\\":0,\\\"lon\\\":0,\\\"zoom\\\":2},\\\"browserLocation\\\":{\\\"zoom\\\":2},\\\"maxZoom\\\":24,\\\"minZoom\\\":0,\\\"showScaleControl\\\":false,\\\"showSpatialFilters\\\":true,\\\"spatialFiltersAlpa\\\":0.3,\\\"spatialFiltersFillColor\\\":\\\"#DA8B45\\\",\\\"spatialFiltersLineColor\\\":\\\"#DA8B45\\\"}}\",\"uiStateJSON\":\"{\\\"isLayerTOCOpen\\\":true,\\\"openTOCDetails\\\":[]}\"},\"mapCenter\":{\"lat\":19.94277,\"lon\":0,\"zoom\":1.75},\"mapBuffer\":{\"minLon\":-211.13072,\"minLat\":-55.27145,\"maxLon\":211.13072,\"maxLat\":87.44135},\"isLayerTOCOpen\":true,\"openTOCDetails\":[],\"hiddenLayers\":[],\"enhancements\":{}}}]", + "optionsJSON" : "{\"hidePanelTitles\":false,\"useMargins\":true}", + "version" : 1, + "timeRestore" : false, + "kibanaSavedObjectMeta" : { + "searchSourceJSON" : "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[]}" + } + }, + "type" : "dashboard", + "references" : [ ], + "migrationVersion" : { + "dashboard" : "7.11.0" + }, + "updated_at" : "2021-06-01T15:37:39.198Z" + } + } +} + { "type": "doc", "value": { From 9810a72720c63a72ef5c5cc43c7af9d09ff165db Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Fri, 4 Jun 2021 16:04:53 +0200 Subject: [PATCH 66/77] [Transform] Support for the `top_metrics` aggregation (#101152) * [ML] init top_metrics agg * [ML] support sort * [ML] support _score sorting * [ML] support sort mode * [ML] support numeric type sorting * [ML] update field label, hide additional sorting controls * [ML] preserve advanced config * [ML] update agg fields after runtime fields edit * [ML] fix TS issue with EuiButtonGroup * [ML] fix Field label * [ML] refactor setUiConfig * [ML] update unit tests * [ML] wrap advanced sorting settings with accordion * [ML] config validation with tests * [ML] fix preserving of the unsupported config * [ML] update translation message * [ML] fix level of the custom config * [ML] preserve unsupported config for sorting --- .../transform/common/types/pivot_aggs.ts | 1 + .../public/app/common/pivot_aggs.test.ts | 11 +- .../transform/public/app/common/pivot_aggs.ts | 70 ++++++- .../advanced_runtime_mappings_settings.tsx | 22 +- .../aggregation_list/popover_form.tsx | 71 +++++-- .../step_define/common/common.test.ts | 3 + .../step_define/common/get_agg_form_config.ts | 3 + .../common/get_default_aggregation_config.ts | 3 + .../common/get_pivot_dropdown_options.ts | 1 + .../components/top_metrics_agg_form.tsx | 195 +++++++++++++++++ .../common/top_metrics_agg/config.test.ts | 196 ++++++++++++++++++ .../common/top_metrics_agg/config.ts | 118 +++++++++++ .../common/top_metrics_agg/types.ts | 24 +++ .../step_define/hooks/use_pivot_config.ts | 4 +- 14 files changed, 693 insertions(+), 29 deletions(-) create mode 100644 x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx create mode 100644 x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts create mode 100644 x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts create mode 100644 x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts diff --git a/x-pack/plugins/transform/common/types/pivot_aggs.ts b/x-pack/plugins/transform/common/types/pivot_aggs.ts index c50852e53254af..ced4d0a9bce0c2 100644 --- a/x-pack/plugins/transform/common/types/pivot_aggs.ts +++ b/x-pack/plugins/transform/common/types/pivot_aggs.ts @@ -17,6 +17,7 @@ export const PIVOT_SUPPORTED_AGGS = { SUM: 'sum', VALUE_COUNT: 'value_count', FILTER: 'filter', + TOP_METRICS: 'top_metrics', } as const; export type PivotSupportedAggs = typeof PIVOT_SUPPORTED_AGGS[keyof typeof PIVOT_SUPPORTED_AGGS]; diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts index dba9fa5dd83ba4..f92bf1cdf59d90 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getAggConfigFromEsAgg } from './pivot_aggs'; +import { getAggConfigFromEsAgg, isSpecialSortField } from './pivot_aggs'; import { FilterAggForm, FilterTermForm, @@ -67,3 +67,12 @@ describe('getAggConfigFromEsAgg', () => { }); }); }); + +describe('isSpecialSortField', () => { + test('detects special sort field', () => { + expect(isSpecialSortField('_score')).toBe(true); + }); + test('rejects special fields that not supported yet', () => { + expect(isSpecialSortField('_doc')).toBe(false); + }); +}); diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts index 03e06d36f9319c..97685096a5d223 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -7,7 +7,7 @@ import { FC } from 'react'; -import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; +import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/common'; import type { AggName } from '../../../common/types/aggregations'; import type { Dictionary } from '../../../common/types/common'; @@ -43,6 +43,7 @@ export const pivotAggsFieldSupport = { PIVOT_SUPPORTED_AGGS.CARDINALITY, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER, + PIVOT_SUPPORTED_AGGS.TOP_METRICS, ], [KBN_FIELD_TYPES.MURMUR3]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], [KBN_FIELD_TYPES.NUMBER]: [ @@ -54,17 +55,78 @@ export const pivotAggsFieldSupport = { PIVOT_SUPPORTED_AGGS.SUM, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER, + PIVOT_SUPPORTED_AGGS.TOP_METRICS, ], [KBN_FIELD_TYPES.STRING]: [ PIVOT_SUPPORTED_AGGS.CARDINALITY, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER, + PIVOT_SUPPORTED_AGGS.TOP_METRICS, ], [KBN_FIELD_TYPES._SOURCE]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], [KBN_FIELD_TYPES.UNKNOWN]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], [KBN_FIELD_TYPES.CONFLICT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT, PIVOT_SUPPORTED_AGGS.FILTER], }; +export const TOP_METRICS_SORT_FIELD_TYPES = [ + KBN_FIELD_TYPES.NUMBER, + KBN_FIELD_TYPES.DATE, + KBN_FIELD_TYPES.GEO_POINT, +]; + +export const SORT_DIRECTION = { + ASC: 'asc', + DESC: 'desc', +} as const; + +export type SortDirection = typeof SORT_DIRECTION[keyof typeof SORT_DIRECTION]; + +export const SORT_MODE = { + MIN: 'min', + MAX: 'max', + AVG: 'avg', + SUM: 'sum', + MEDIAN: 'median', +} as const; + +export const NUMERIC_TYPES_OPTIONS = { + [KBN_FIELD_TYPES.NUMBER]: [ES_FIELD_TYPES.DOUBLE, ES_FIELD_TYPES.LONG], + [KBN_FIELD_TYPES.DATE]: [ES_FIELD_TYPES.DATE, ES_FIELD_TYPES.DATE_NANOS], +}; + +export type KbnNumericType = typeof KBN_FIELD_TYPES.NUMBER | typeof KBN_FIELD_TYPES.DATE; + +const SORT_NUMERIC_FIELD_TYPES = [ + ES_FIELD_TYPES.DOUBLE, + ES_FIELD_TYPES.LONG, + ES_FIELD_TYPES.DATE, + ES_FIELD_TYPES.DATE_NANOS, +] as const; + +export type SortNumericFieldType = typeof SORT_NUMERIC_FIELD_TYPES[number]; + +export type SortMode = typeof SORT_MODE[keyof typeof SORT_MODE]; + +export const TOP_METRICS_SPECIAL_SORT_FIELDS = { + _SCORE: '_score', +} as const; + +export const isSpecialSortField = (sortField: unknown) => { + return Object.values(TOP_METRICS_SPECIAL_SORT_FIELDS).some((v) => v === sortField); +}; + +export const isValidSortDirection = (arg: unknown): arg is SortDirection => { + return Object.values(SORT_DIRECTION).some((v) => v === arg); +}; + +export const isValidSortMode = (arg: unknown): arg is SortMode => { + return Object.values(SORT_MODE).some((v) => v === arg); +}; + +export const isValidSortNumericType = (arg: unknown): arg is SortNumericFieldType => { + return SORT_NUMERIC_FIELD_TYPES.some((v) => v === arg); +}; + /** * The maximum level of sub-aggregations */ @@ -75,6 +137,10 @@ export interface PivotAggsConfigBase { agg: PivotSupportedAggs; aggName: AggName; dropDownName: string; + /** + * Indicates if aggregation supports multiple fields + */ + isMultiField?: boolean; /** Indicates if aggregation supports sub-aggregations */ isSubAggsSupported?: boolean; /** Dictionary of the sub-aggregations */ @@ -130,7 +196,7 @@ export function getAggConfigFromEsAgg( } export interface PivotAggsConfigWithUiBase extends PivotAggsConfigBase { - field: EsFieldName; + field: EsFieldName | EsFieldName[]; } export interface PivotAggsConfigWithExtra extends PivotAggsConfigWithUiBase { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx index 29e341fdaeaea9..4e70b7d7fe9b7a 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/advanced_runtime_mappings_settings/advanced_runtime_mappings_settings.tsx @@ -46,7 +46,7 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = }, } = props.runtimeMappingsEditor; const { - actions: { deleteAggregation, deleteGroupBy }, + actions: { deleteAggregation, deleteGroupBy, updateAggregation }, state: { groupByList, aggList }, } = props.pivotConfig; @@ -55,6 +55,9 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = advancedRuntimeMappingsConfig === '' ? {} : JSON.parse(advancedRuntimeMappingsConfig); const previousConfig = runtimeMappings; + const isFieldDeleted = (field: string) => + previousConfig?.hasOwnProperty(field) && !nextConfig.hasOwnProperty(field); + applyRuntimeMappingsEditorChanges(); // If the user updates the name of the runtime mapping fields @@ -71,13 +74,16 @@ export const AdvancedRuntimeMappingsSettings: FC = (props) = }); Object.keys(aggList).forEach((aggName) => { const agg = aggList[aggName] as PivotAggsConfigWithUiSupport; - if ( - isPivotAggConfigWithUiSupport(agg) && - agg.field !== undefined && - previousConfig?.hasOwnProperty(agg.field) && - !nextConfig.hasOwnProperty(agg.field) - ) { - deleteAggregation(aggName); + + if (isPivotAggConfigWithUiSupport(agg)) { + if (Array.isArray(agg.field)) { + const newFields = agg.field.filter((f) => !isFieldDeleted(f)); + updateAggregation(aggName, { ...agg, field: newFields }); + } else { + if (agg.field !== undefined && isFieldDeleted(agg.field)) { + deleteAggregation(aggName); + } + } } }); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx index 553581f58d55e1..fd11255374a517 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, EuiCodeEditor, + EuiComboBox, EuiFieldText, EuiForm, EuiFormRow, @@ -79,7 +80,7 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha const [aggName, setAggName] = useState(defaultData.aggName); const [agg, setAgg] = useState(defaultData.agg); - const [field, setField] = useState( + const [field, setField] = useState( isPivotAggsConfigWithUiSupport(defaultData) ? defaultData.field : '' ); @@ -148,13 +149,21 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha if (!isUnsupportedAgg) { const optionsArr = dictionaryToArray(options); + optionsArr .filter((o) => o.agg === defaultData.agg) .forEach((o) => { availableFields.push({ text: o.field }); }); + optionsArr - .filter((o) => isPivotAggsConfigWithUiSupport(defaultData) && o.field === defaultData.field) + .filter( + (o) => + isPivotAggsConfigWithUiSupport(defaultData) && + (Array.isArray(defaultData.field) + ? defaultData.field.includes(o.field as string) + : o.field === defaultData.field) + ) .forEach((o) => { availableAggs.push({ text: o.agg }); }); @@ -217,20 +226,48 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha data-test-subj="transformAggName" /> - {availableFields.length > 0 && ( - - setField(e.target.value)} - data-test-subj="transformAggField" - /> - - )} + {availableFields.length > 0 ? ( + aggConfigDef.isMultiField ? ( + + { + return { + value: v.text, + label: v.text as string, + }; + })} + selectedOptions={(typeof field === 'string' ? [field] : field).map((v) => ({ + value: v, + label: v, + }))} + onChange={(e) => { + const res = e.map((v) => v.value as string); + setField(res); + }} + isClearable={false} + data-test-subj="transformAggFields" + /> + + ) : ( + + setField(e.target.value)} + data-test-subj="transformAggField" + /> + + ) + ) : null} {availableAggs.length > 0 && ( = ({ defaultData, otherAggNames, onCha {isPivotAggsWithExtendedForm(aggConfigDef) && ( { setAggConfigDef({ ...aggConfigDef, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts index fcdbac8c7ff39c..5891e8b330b949 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts @@ -44,6 +44,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'sum( the-f[i]e>ld )' }, { label: 'value_count( the-f[i]e>ld )' }, { label: 'filter( the-f[i]e>ld )' }, + { label: 'top_metrics( the-f[i]e>ld )' }, ], }, ], @@ -133,6 +134,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'sum( the-f[i]e>ld )' }, { label: 'value_count( the-f[i]e>ld )' }, { label: 'filter( the-f[i]e>ld )' }, + { label: 'top_metrics( the-f[i]e>ld )' }, ], }, { @@ -146,6 +148,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'sum(rt_bytes_bigger)' }, { label: 'value_count(rt_bytes_bigger)' }, { label: 'filter(rt_bytes_bigger)' }, + { label: 'top_metrics(rt_bytes_bigger)' }, ], }, ], diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts index ab69d22b1f3d7b..5d8d7cb967b658 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_agg_form_config.ts @@ -12,6 +12,7 @@ import { import { PivotAggsConfigBase, PivotAggsConfigWithUiBase } from '../../../../../common/pivot_aggs'; import { getFilterAggConfig } from './filter_agg/config'; +import { getTopMetricsAggConfig } from './top_metrics_agg/config'; /** * Gets form configuration for provided aggregation type. @@ -23,6 +24,8 @@ export function getAggFormConfig( switch (agg) { case PIVOT_SUPPORTED_AGGS.FILTER: return getFilterAggConfig(commonConfig); + case PIVOT_SUPPORTED_AGGS.TOP_METRICS: + return getTopMetricsAggConfig(commonConfig); default: return commonConfig; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts index 53350f0238bf06..39594dcbff9ae2 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_default_aggregation_config.ts @@ -15,6 +15,7 @@ import { PivotAggsConfigWithUiSupport, } from '../../../../../common'; import { getFilterAggConfig } from './filter_agg/config'; +import { getTopMetricsAggConfig } from './top_metrics_agg/config'; /** * Provides a configuration based on the aggregation type. @@ -41,6 +42,8 @@ export function getDefaultAggregationConfig( }; case PIVOT_SUPPORTED_AGGS.FILTER: return getFilterAggConfig(commonConfig); + case PIVOT_SUPPORTED_AGGS.TOP_METRICS: + return getTopMetricsAggConfig(commonConfig); default: return commonConfig; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts index 300626e0570ae2..b17f30d115f4a2 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts @@ -141,6 +141,7 @@ export function getPivotDropdownOptions( }); return { + fields: combinedFields, groupByOptions, groupByOptionsData, aggOptions, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx new file mode 100644 index 00000000000000..0ec66a3d59a113 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/components/top_metrics_agg_form.tsx @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiSelect, EuiButtonGroup, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { PivotAggsConfigTopMetrics, TopMetricsAggConfig } from '../types'; +import { PivotConfigurationContext } from '../../../../pivot_configuration/pivot_configuration'; +import { + isSpecialSortField, + KbnNumericType, + NUMERIC_TYPES_OPTIONS, + SORT_DIRECTION, + SORT_MODE, + SortDirection, + SortMode, + SortNumericFieldType, + TOP_METRICS_SORT_FIELD_TYPES, + TOP_METRICS_SPECIAL_SORT_FIELDS, +} from '../../../../../../../common/pivot_aggs'; + +export const TopMetricsAggForm: PivotAggsConfigTopMetrics['AggFormComponent'] = ({ + onChange, + aggConfig, +}) => { + const { + state: { fields }, + } = useContext(PivotConfigurationContext)!; + + const sortFieldOptions = fields + .filter((v) => TOP_METRICS_SORT_FIELD_TYPES.includes(v.type)) + .map(({ name }) => ({ text: name, value: name })); + + Object.values(TOP_METRICS_SPECIAL_SORT_FIELDS).forEach((v) => { + sortFieldOptions.unshift({ text: v, value: v }); + }); + sortFieldOptions.unshift({ text: '', value: '' }); + + const isSpecialFieldSelected = isSpecialSortField(aggConfig.sortField); + + const sortDirectionOptions = Object.values(SORT_DIRECTION).map((v) => ({ + id: v, + label: v, + })); + + const sortModeOptions = Object.values(SORT_MODE).map((v) => ({ + id: v, + label: v, + })); + + const sortFieldType = fields.find((f) => f.name === aggConfig.sortField)?.type; + + const sortSettings = aggConfig.sortSettings ?? {}; + + const updateSortSettings = useCallback( + (update: Partial) => { + onChange({ + ...aggConfig, + sortSettings: { + ...(aggConfig.sortSettings ?? {}), + ...update, + }, + }); + }, + [aggConfig, onChange] + ); + + return ( + <> + + } + > + { + onChange({ ...aggConfig, sortField: e.target.value }); + }} + data-test-subj="transformSortFieldTopMetricsLabel" + /> + + + {aggConfig.sortField ? ( + <> + {isSpecialFieldSelected ? null : ( + <> + + } + > + { + updateSortSettings({ order: id as SortDirection }); + }} + color="text" + /> + + + + + + } + > + + } + helpText={ + + } + > + { + updateSortSettings({ mode: id as SortMode }); + }} + color="text" + /> + + + {sortFieldType && NUMERIC_TYPES_OPTIONS.hasOwnProperty(sortFieldType) ? ( + + } + > + ({ + text: v, + name: v, + }))} + value={sortSettings.numericType} + onChange={(e) => { + updateSortSettings({ + numericType: e.target.value as SortNumericFieldType, + }); + }} + data-test-subj="transformSortNumericTypeTopMetricsLabel" + /> + + ) : null} + + + )} + + ) : null} + + ); +}; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts new file mode 100644 index 00000000000000..ef57e6d1295c1a --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.test.ts @@ -0,0 +1,196 @@ +/* + * 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 { getTopMetricsAggConfig } from './config'; +import { PivotAggsConfigTopMetrics } from './types'; + +describe('top metrics agg config', () => { + let config: PivotAggsConfigTopMetrics; + + beforeEach(() => { + config = getTopMetricsAggConfig({ + agg: 'top_metrics', + aggName: 'test-agg', + field: ['test-field'], + dropDownName: 'test-agg', + }); + }); + + describe('#setUiConfigFromEs', () => { + test('sets config with a special field', () => { + // act + config.setUiConfigFromEs({ + metrics: { + field: 'test-field-01', + }, + sort: '_score', + }); + + // assert + expect(config.field).toEqual(['test-field-01']); + expect(config.aggConfig).toEqual({ + sortField: '_score', + }); + }); + + test('sets config with a simple sort direction definition', () => { + // act + config.setUiConfigFromEs({ + metrics: [ + { + field: 'test-field-01', + }, + { + field: 'test-field-02', + }, + ], + sort: { + 'sort-field': 'asc', + }, + }); + + // assert + expect(config.field).toEqual(['test-field-01', 'test-field-02']); + expect(config.aggConfig).toEqual({ + sortField: 'sort-field', + sortSettings: { + order: 'asc', + }, + }); + }); + + test('sets config with a sort definition params not supported by the UI', () => { + // act + config.setUiConfigFromEs({ + metrics: [ + { + field: 'test-field-01', + }, + ], + sort: { + 'offer.price': { + order: 'desc', + mode: 'avg', + nested: { + path: 'offer', + filter: { + term: { 'offer.color': 'blue' }, + }, + }, + }, + }, + }); + + // assert + expect(config.field).toEqual(['test-field-01']); + expect(config.aggConfig).toEqual({ + sortField: 'offer.price', + sortSettings: { + order: 'desc', + mode: 'avg', + nested: { + path: 'offer', + filter: { + term: { 'offer.color': 'blue' }, + }, + }, + }, + }); + }); + }); + + describe('#getEsAggConfig', () => { + test('rejects invalid config', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortSettings: { + order: 'asc', + }, + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual(null); + }); + + test('rejects invalid config with missing sort direction', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortField: 'sort-field', + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual(null); + }); + + test('converts valid config', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortField: 'sort-field', + sortSettings: { + order: 'asc', + }, + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual({ + metrics: [{ field: 'field-01' }, { field: 'field-02' }], + sort: { + 'sort-field': 'asc', + }, + }); + }); + + test('preserves unsupported config', () => { + // arrange + config.field = ['field-01', 'field-02']; + + config.aggConfig = { + sortField: 'sort-field', + sortSettings: { + order: 'asc', + // @ts-ignore + nested: { + path: 'order', + }, + }, + // @ts-ignore + size: 2, + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual({ + metrics: [{ field: 'field-01' }, { field: 'field-02' }], + sort: { + 'sort-field': { + order: 'asc', + nested: { + path: 'order', + }, + }, + }, + size: 2, + }); + }); + + test('converts configs with a special sorting field', () => { + // arrange + config.field = ['field-01', 'field-02']; + config.aggConfig = { + sortField: '_score', + }; + + // act and assert + expect(config.getEsAggConfig()).toEqual({ + metrics: [{ field: 'field-01' }, { field: 'field-02' }], + sort: '_score', + }); + }); + }); +}); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts new file mode 100644 index 00000000000000..56d17e7973e160 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/config.ts @@ -0,0 +1,118 @@ +/* + * 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 { + isPivotAggsConfigWithUiSupport, + isSpecialSortField, + isValidSortDirection, + isValidSortMode, + isValidSortNumericType, + PivotAggsConfigBase, + PivotAggsConfigWithUiBase, +} from '../../../../../../common/pivot_aggs'; +import { PivotAggsConfigTopMetrics } from './types'; +import { TopMetricsAggForm } from './components/top_metrics_agg_form'; +import { isPopulatedObject } from '../../../../../../../../common/shared_imports'; + +/** + * Gets initial basic configuration of the top_metrics aggregation. + */ +export function getTopMetricsAggConfig( + commonConfig: PivotAggsConfigWithUiBase | PivotAggsConfigBase +): PivotAggsConfigTopMetrics { + return { + ...commonConfig, + isSubAggsSupported: false, + isMultiField: true, + field: isPivotAggsConfigWithUiSupport(commonConfig) ? commonConfig.field : '', + AggFormComponent: TopMetricsAggForm, + aggConfig: {}, + getEsAggConfig() { + // ensure the configuration has been completed + if (!this.isValid()) { + return null; + } + + const { sortField, sortSettings = {}, ...unsupportedConfig } = this.aggConfig; + + let sort = null; + + if (isSpecialSortField(sortField)) { + sort = sortField; + } else { + const { mode, numericType, order, ...rest } = sortSettings; + + if (mode || numericType || isPopulatedObject(rest)) { + sort = { + [sortField!]: { + ...rest, + order, + ...(mode ? { mode } : {}), + ...(numericType ? { numeric_type: numericType } : {}), + }, + }; + } else { + sort = { [sortField!]: sortSettings.order }; + } + } + + return { + metrics: (Array.isArray(this.field) ? this.field : [this.field]).map((f) => ({ field: f })), + sort, + ...(unsupportedConfig ?? {}), + }; + }, + setUiConfigFromEs(esAggDefinition) { + const { metrics, sort, ...unsupportedConfig } = esAggDefinition; + + this.field = (Array.isArray(metrics) ? metrics : [metrics]).map((v) => v.field); + + if (isSpecialSortField(sort)) { + this.aggConfig.sortField = sort; + return; + } + + const sortField = Object.keys(sort)[0]; + + this.aggConfig.sortField = sortField; + + const sortDefinition = sort[sortField]; + + this.aggConfig.sortSettings = this.aggConfig.sortSettings ?? {}; + + if (isValidSortDirection(sortDefinition)) { + this.aggConfig.sortSettings.order = sortDefinition; + } + + if (isPopulatedObject(sortDefinition)) { + const { order, mode, numeric_type: numType, ...rest } = sortDefinition; + this.aggConfig.sortSettings = rest; + + if (isValidSortDirection(order)) { + this.aggConfig.sortSettings.order = order; + } + if (isValidSortMode(mode)) { + this.aggConfig.sortSettings.mode = mode; + } + if (isValidSortNumericType(numType)) { + this.aggConfig.sortSettings.numericType = numType; + } + } + + this.aggConfig = { + ...this.aggConfig, + ...(unsupportedConfig ?? {}), + }; + }, + isValid() { + return ( + !!this.aggConfig.sortField && + (isSpecialSortField(this.aggConfig.sortField) ? true : !!this.aggConfig.sortSettings?.order) + ); + }, + }; +} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts new file mode 100644 index 00000000000000..a90ee5307a18ec --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/top_metrics_agg/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + PivotAggsConfigWithExtra, + SortDirection, + SortMode, + SortNumericFieldType, +} from '../../../../../../common/pivot_aggs'; + +export interface TopMetricsAggConfig { + sortField: string; + sortSettings?: { + order?: SortDirection; + mode?: SortMode; + numericType?: SortNumericFieldType; + }; +} + +export type PivotAggsConfigTopMetrics = PivotAggsConfigWithExtra; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts index 4bd8f5cea60926..0c31b4fe2da819 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -97,7 +97,7 @@ export const usePivotConfig = ( ) => { const toastNotifications = useToastNotifications(); - const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData } = useMemo( + const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData, fields } = useMemo( () => getPivotDropdownOptions(indexPattern, defaults.runtimeMappings), [defaults.runtimeMappings, indexPattern] ); @@ -347,6 +347,7 @@ export const usePivotConfig = ( pivotGroupByArr, validationStatus, requestPayload, + fields, }, }; }, [ @@ -361,6 +362,7 @@ export const usePivotConfig = ( pivotGroupByArr, validationStatus, requestPayload, + fields, ]); }; From 690e81aa6098093bde58d16283703c5d16bf6642 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 4 Jun 2021 15:52:25 +0100 Subject: [PATCH 67/77] chore(NA): include missing dependency on @kbn/legacy-logging (#101331) --- packages/kbn-legacy-logging/BUILD.bazel | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kbn-legacy-logging/BUILD.bazel b/packages/kbn-legacy-logging/BUILD.bazel index 21cb8c338f89f9..1fd04604dbd244 100644 --- a/packages/kbn-legacy-logging/BUILD.bazel +++ b/packages/kbn-legacy-logging/BUILD.bazel @@ -25,6 +25,7 @@ NPM_MODULE_EXTRA_FILES = [ SRC_DEPS = [ "//packages/kbn-config-schema", + "//packages/kbn-utils", "@npm//@elastic/numeral", "@npm//@hapi/hapi", "@npm//chokidar", From 36996634c3eb4f48eb3dcc904f7ffffae1c4f499 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Fri, 4 Jun 2021 10:59:53 -0400 Subject: [PATCH 68/77] [Security Solution][Endpoint] Add ability to isolate the Host from the Endpoint Details flyout (#100482) * Add un-isolate form to the endpoint flyout * Add Endpoint details flyout footer and action button * Refactor hooks into a directory * Refactor endpoint list actions into reusable list + add it to Take action on details * Refactor Endpoint list row actions to use new common hook for items * generate different values for isolation in endpoint generator * move `isEndpointHostIsolated()` utility to a common folder * refactor detections to also use common `isEndpointHostIsolated()` * httpHandlerMockFactory can now handle API paths with params (`{id}`) * Initial set of re-usable http mocks for endpoint hosts set of pages * fix bug in `composeHttpHandlerMocks()` * small improvements to test utilities * Show API errors for isolate in Form standard place --- .../common/endpoint/types/index.ts | 9 + .../mock/endpoint/app_context_render.tsx | 3 + .../endpoint/http_handler_mock_factory.ts | 43 ++++- .../public/common/store/test_utils.ts | 7 +- .../public/common/utils/validators/index.ts | 2 + .../is_endpoint_host_isolated.test.ts | 36 ++++ .../validators/is_endpoint_host_isolated.ts | 17 ++ .../alerts/use_host_isolation_status.tsx | 3 +- .../public/management/common/routing.ts | 5 +- .../management/pages/endpoint_hosts/mocks.ts | 128 +++++++++++++++ .../pages/endpoint_hosts/store/action.ts | 6 +- .../endpoint_hosts/store/middleware.test.ts | 27 ++- .../pages/endpoint_hosts/store/middleware.ts | 10 +- .../pages/endpoint_hosts/store/selectors.ts | 22 ++- .../management/pages/endpoint_hosts/types.ts | 2 +- .../context_menu_item_nav_by_rotuer.tsx | 36 ++++ .../view/components/table_row_actions.tsx | 48 ++---- .../details/components/actions_menu.test.tsx | 113 +++++++++++++ .../view/details/components/actions_menu.tsx | 63 +++++++ .../endpoint_isolate_flyout_panel.tsx | 55 ++++--- .../endpoint_hosts/view/details/index.tsx | 17 +- .../endpoint_hosts/view/{ => hooks}/hooks.ts | 10 +- .../pages/endpoint_hosts/view/hooks/index.ts | 9 + .../view/hooks/use_endpoint_action_items.tsx | 155 ++++++++++++++++++ .../pages/endpoint_hosts/view/index.test.tsx | 48 ++++-- .../pages/endpoint_hosts/view/index.tsx | 102 +----------- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../metadata/destination_index/data.json | 24 ++- 29 files changed, 799 insertions(+), 207 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx rename x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/{ => hooks}/hooks.ts (89%) create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index c084dd8ca76680..4367c0d90af79c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -468,14 +468,23 @@ export type HostMetadata = Immutable<{ id: string; status: HostPolicyResponseActionStatus; name: string; + /** The endpoint integration policy revision number in kibana */ endpoint_policy_version: number; version: number; }; }; configuration: { + /** + * Shows whether the endpoint is set up to be isolated. (e.g. a user has isolated a host, + * and the endpoint successfully received that action and applied the setting) + */ isolation?: boolean; }; state: { + /** + * Shows what the current state of the host is. This could differ from `Endpoint.configuration.isolation` + * in some cases, but normally they will match + */ isolation?: boolean; }; }; diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index ae2cc59de6abfb..d96929ec183d8d 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -23,6 +23,7 @@ import { ExperimentalFeatures } from '../../../../common/experimental_features'; import { PLUGIN_ID } from '../../../../../fleet/common'; import { APP_ID } from '../../../../common/constants'; import { KibanaContextProvider } from '../../lib/kibana'; +import { MANAGEMENT_APP_ID } from '../../../management/common/constants'; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; @@ -156,6 +157,8 @@ const createCoreStartMock = (): ReturnType => { return '/app/fleet'; case APP_ID: return '/app/security'; + case MANAGEMENT_APP_ID: + return '/app/security/administration'; default: return `${appId} not mocked!`; } diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts index 9d12efca19aed0..2df16fc1e21b0a 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts @@ -13,7 +13,7 @@ import type { HttpHandler, HttpStart, } from 'kibana/public'; -import { extend } from 'lodash'; +import { merge } from 'lodash'; import { act } from '@testing-library/react'; class ApiRouteNotMocked extends Error {} @@ -159,6 +159,11 @@ export const httpHandlerMockFactory = ['responseProvider'] = mocks.reduce( (providers, routeMock) => { // FIXME: find a way to remove the ignore below. May need to limit the calling signature of `RouteMock['handler']` @@ -195,7 +200,7 @@ export const httpHandlerMockFactory = { const path = isHttpFetchOptionsWithPath(args[0]) ? args[0].path : args[0]; - const routeMock = methodMocks.find((handler) => handler.path === path); + const routeMock = methodMocks.find((handler) => pathMatchesPattern(handler.path, path)); if (routeMock) { markApiCallAsHandled(responseProvider[routeMock.id].mockDelay); @@ -211,6 +216,9 @@ export const httpHandlerMockFactory = { + // No path params - pattern is single path + if (pathPattern === path) { + return true; + } + + // If pathPattern has params (`{value}`), then see if `path` matches it + if (/{.*?}/.test(pathPattern)) { + const pathParts = path.split(/\//); + const patternParts = pathPattern.split(/\//); + + if (pathParts.length !== patternParts.length) { + return false; + } + + return pathParts.every((part, index) => { + return part === patternParts[index] || /{.*?}/.test(patternParts[index]); + }); + } + + return false; +}; + const isHttpFetchOptionsWithPath = ( opt: string | HttpFetchOptions | HttpFetchOptionsWithPath ): opt is HttpFetchOptionsWithPath => { @@ -235,12 +266,14 @@ const isHttpFetchOptionsWithPath = ( * @example * import { composeApiHandlerMocks } from './http_handler_mock_factory'; * import { + * FleetSetupApiMockInterface, * fleetSetupApiMock, + * AgentsSetupApiMockInterface, * agentsSetupApiMock, * } from './setup'; * - * // Create the new interface as an intersection of all other Api Handler Mocks - * type ComposedApiHandlerMocks = ReturnType & ReturnType + * // Create the new interface as an intersection of all other Api Handler Mock's interfaces + * type ComposedApiHandlerMocks = AgentsSetupApiMockInterface & FleetSetupApiMockInterface * * const newComposedHandlerMock = composeApiHandlerMocks< * ComposedApiHandlerMocks @@ -267,7 +300,7 @@ export const composeHttpHandlerMocks = < handlerMocks.forEach((handlerMock) => { const { waitForApi, ...otherInterfaceProps } = handlerMock(http); - extend(mockedApiInterfaces, otherInterfaceProps); + merge(mockedApiInterfaces, otherInterfaceProps); }); return mockedApiInterfaces; diff --git a/x-pack/plugins/security_solution/public/common/store/test_utils.ts b/x-pack/plugins/security_solution/public/common/store/test_utils.ts index 7616dfccddaff7..21c8e6c15f8263 100644 --- a/x-pack/plugins/security_solution/public/common/store/test_utils.ts +++ b/x-pack/plugins/security_solution/public/common/store/test_utils.ts @@ -89,7 +89,9 @@ export const createSpyMiddleware = < type ResolvedAction = A extends { type: typeof actionType } ? A : never; // Error is defined here so that we get a better stack trace that points to the test from where it was used - const err = new Error(`action '${actionType}' was not dispatched within the allocated time`); + const err = new Error( + `Timeout! Action '${actionType}' was not dispatched within the allocated time` + ); return new Promise((resolve, reject) => { const watch: ActionWatcher = (action) => { @@ -108,7 +110,10 @@ export const createSpyMiddleware = < const timeout = setTimeout(() => { watchers.delete(watch); reject(err); + // TODO: is there a way we can grab the current timeout value from jest? + // For now, this is using the default value (5000ms) - 500. }, 4500); + watchers.add(watch); }); }, diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts index 7f470c199d550f..178ae3b0f716e8 100644 --- a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts +++ b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts @@ -7,6 +7,8 @@ import { isEmpty } from 'lodash/fp'; +export * from './is_endpoint_host_isolated'; + const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi; export const isUrlInvalid = (url: string | null | undefined) => { diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts new file mode 100644 index 00000000000000..2e96d56c3625f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { HostMetadata } from '../../../../common/endpoint/types'; +import { isEndpointHostIsolated } from './is_endpoint_host_isolated'; + +describe('When using isEndpointHostIsolated()', () => { + const generator = new EndpointDocGenerator(); + + const generateMetadataDoc = (isolation: boolean = true) => { + const metadataDoc = generator.generateHostMetadata() as HostMetadata; + return { + ...metadataDoc, + Endpoint: { + ...metadataDoc.Endpoint, + state: { + ...metadataDoc.Endpoint.state, + isolation, + }, + }, + }; + }; + + it('Returns `true` when endpoint is isolated', () => { + expect(isEndpointHostIsolated(generateMetadataDoc())).toBe(true); + }); + + it('Returns `false` when endpoint is isolated', () => { + expect(isEndpointHostIsolated(generateMetadataDoc(false))).toBe(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts new file mode 100644 index 00000000000000..6ca187c52475e9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/validators/is_endpoint_host_isolated.ts @@ -0,0 +1,17 @@ +/* + * 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 { HostMetadata } from '../../../../common/endpoint/types'; + +/** + * Given an endpoint host metadata record (`HostMetadata`), this utility will validate if + * that host is isolated + * @param endpointMetadata + */ +export const isEndpointHostIsolated = (endpointMetadata: HostMetadata): boolean => { + return Boolean(endpointMetadata.Endpoint.state.isolation); +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx index adc6d3a6b054b8..f7894d47642755 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx @@ -11,6 +11,7 @@ import { Maybe } from '../../../../../../observability/common/typings'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { getHostMetadata } from './api'; import { ISOLATION_STATUS_FAILURE } from './translations'; +import { isEndpointHostIsolated } from '../../../../common/utils/validators'; interface HostIsolationStatusResponse { loading: boolean; @@ -36,7 +37,7 @@ export const useHostIsolationStatus = ({ try { const metadataResponse = await getHostMetadata({ agentId }); if (isMounted) { - setIsIsolated(Boolean(metadataResponse.metadata.Endpoint.state.isolation)); + setIsIsolated(isEndpointHostIsolated(metadataResponse.metadata)); } } catch (error) { addError(error.message, { title: ISOLATION_STATUS_FAILURE }); diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 5bafecb8c4ff5b..93d0642c6b3b6f 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -66,7 +66,7 @@ export const getEndpointListPath = ( export const getEndpointDetailsPath = ( props: { - name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate'; + name: 'endpointDetails' | 'endpointPolicyResponse' | 'endpointIsolate' | 'endpointUnIsolate'; } & EndpointIndexUIQueryParams & EndpointDetailsUrlProps, search?: string @@ -79,6 +79,9 @@ export const getEndpointDetailsPath = ( case 'endpointIsolate': queryParams.show = 'isolate'; break; + case 'endpointUnIsolate': + queryParams.show = 'unisolate'; + break; case 'endpointPolicyResponse': queryParams.show = 'policy_response'; break; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts new file mode 100644 index 00000000000000..3a3ad47f9f5754 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.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 { + composeHttpHandlerMocks, + httpHandlerMockFactory, + ResponseProvidersInterface, +} from '../../../common/mock/endpoint/http_handler_mock_factory'; +import { + HostInfo, + HostPolicyResponse, + HostResultList, + HostStatus, + MetadataQueryStrategyVersions, +} from '../../../../common/endpoint/types'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { + BASE_POLICY_RESPONSE_ROUTE, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, +} from '../../../../common/endpoint/constants'; +import { AGENT_POLICY_API_ROUTES, GetAgentPoliciesResponse } from '../../../../../fleet/common'; + +type EndpointMetadataHttpMocksInterface = ResponseProvidersInterface<{ + metadataList: () => HostResultList; + metadataDetails: () => HostInfo; +}>; +export const endpointMetadataHttpMocks = httpHandlerMockFactory( + [ + { + id: 'metadataList', + path: HOST_METADATA_LIST_ROUTE, + method: 'post', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + + return { + hosts: Array.from({ length: 10 }, () => { + return { + metadata: generator.generateHostMetadata(), + host_status: HostStatus.UNHEALTHY, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }), + total: 10, + request_page_size: 10, + request_page_index: 0, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }, + }, + { + id: 'metadataDetails', + path: HOST_METADATA_GET_ROUTE, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + + return { + metadata: generator.generateHostMetadata(), + host_status: HostStatus.UNHEALTHY, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, + }; + }, + }, + ] +); + +type EndpointPolicyResponseHttpMockInterface = ResponseProvidersInterface<{ + policyResponse: () => HostPolicyResponse; +}>; +export const endpointPolicyResponseHttpMock = httpHandlerMockFactory( + [ + { + id: 'policyResponse', + path: BASE_POLICY_RESPONSE_ROUTE, + method: 'get', + handler: () => { + return new EndpointDocGenerator('seed').generatePolicyResponse(); + }, + }, + ] +); + +type FleetApisHttpMockInterface = ResponseProvidersInterface<{ + agentPolicy: () => GetAgentPoliciesResponse; +}>; +export const fleetApisHttpMock = httpHandlerMockFactory([ + { + id: 'agentPolicy', + path: AGENT_POLICY_API_ROUTES.LIST_PATTERN, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + const endpointMetadata = generator.generateHostMetadata(); + const agentPolicy = generator.generateAgentPolicy(); + + // Make sure that the Agent policy returned from the API has the Integration Policy ID that + // the endpoint metadata is using. This is needed especially when testing the Endpoint Details + // flyout where certain actions might be disabled if we know the endpoint integration policy no + // longer exists. + (agentPolicy.package_policies as string[]).push(endpointMetadata.Endpoint.policy.applied.id); + + return { + items: [agentPolicy], + perPage: 10, + total: 1, + page: 1, + }; + }, + }, +]); + +type EndpointPageHttpMockInterface = EndpointMetadataHttpMocksInterface & + EndpointPolicyResponseHttpMockInterface & + FleetApisHttpMockInterface; +/** + * HTTP Mocks that support the Endpoint List and Details page + */ +export const endpointPageHttpMock = composeHttpHandlerMocks([ + endpointMetadataHttpMocks, + endpointPolicyResponseHttpMock, + fleetApisHttpMock, +]); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 25f2631ef46ff7..178f27caa10853 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -11,6 +11,7 @@ import { HostInfo, GetHostPolicyResponse, HostIsolationRequestBody, + ISOLATION_ACTIONS, } from '../../../../../common/endpoint/types'; import { ServerApiError } from '../../../../common/types'; import { GetPolicyListResponse } from '../../policy/types'; @@ -137,7 +138,10 @@ export interface ServerFailedToReturnEndpointsTotal { } export type EndpointIsolationRequest = Action<'endpointIsolationRequest'> & { - payload: HostIsolationRequestBody; + payload: { + type: ISOLATION_ACTIONS; + data: HostIsolationRequestBody; + }; }; export type EndpointIsolationRequestStateChange = Action<'endpointIsolationRequestStateChange'> & { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index 04a04bc38996b8..6548d8a10ce97f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -19,6 +19,7 @@ import { HostResultList, HostIsolationResponse, EndpointAction, + ISOLATION_ACTIONS, } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { mockEndpointResultList } from './mock_endpoint_result_list'; @@ -135,10 +136,13 @@ describe('endpoint list middleware', () => { describe('handling of IsolateEndpointHost action', () => { const getKibanaServicesMock = KibanaServices.get as jest.Mock; - const dispatchIsolateEndpointHost = () => { + const dispatchIsolateEndpointHost = (action: ISOLATION_ACTIONS = 'isolate') => { dispatch({ type: 'endpointIsolationRequest', - payload: hostIsolationRequestBodyMock(), + payload: { + type: action, + data: hostIsolationRequestBodyMock(), + }, }); }; let isolateApiResponseHandlers: ReturnType; @@ -161,7 +165,24 @@ describe('endpoint list middleware', () => { it('should call isolate api', async () => { dispatchIsolateEndpointHost(); - expect(fakeHttpServices.post).toHaveBeenCalled(); + await waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + expect(isolateApiResponseHandlers.responseProvider.isolateHost).toHaveBeenCalled(); + }); + + it('should call unisolate api', async () => { + dispatchIsolateEndpointHost('unisolate'); + await waitForAction('endpointIsolationRequestStateChange', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + + expect(isolateApiResponseHandlers.responseProvider.unIsolateHost).toHaveBeenCalled(); }); it('should set Isolation state to loaded if api is successful', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 911a902bd2029e..b62663bd787503 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -51,7 +51,7 @@ import { createLoadedResourceState, createLoadingResourceState, } from '../../../state'; -import { isolateHost } from '../../../../common/lib/host_isolation'; +import { isolateHost, unIsolateHost } from '../../../../common/lib/host_isolation'; import { AppAction } from '../../../../common/store/actions'; import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; @@ -504,7 +504,13 @@ const handleIsolateEndpointHost = async ( try { // Cast needed below due to the value of payload being `Immutable<>` - const response = await isolateHost(action.payload as HostIsolationRequestBody); + let response: HostIsolationResponse; + + if (action.payload.type === 'unisolate') { + response = await unIsolateHost(action.payload.data as HostIsolationRequestBody); + } else { + response = await isolateHost(action.payload.data as HostIsolationRequestBody); + } dispatch({ type: 'endpointIsolationRequestStateChange', diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 8b6599611ffc40..f3848557567ec8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -32,6 +32,7 @@ import { isLoadingResourceState, } from '../../../state'; import { ServerApiError } from '../../../../common/types'; +import { isEndpointHostIsolated } from '../../../../common/utils/validators'; export const listData = (state: Immutable) => state.hosts; @@ -204,6 +205,14 @@ export const uiQueryParams: ( 'admin_query', ]; + const allowedShowValues: Array = [ + 'policy_response', + 'details', + 'isolate', + 'unisolate', + 'activity_log', + ]; + for (const key of keys) { const value: string | undefined = typeof query[key] === 'string' @@ -214,13 +223,8 @@ export const uiQueryParams: ( if (value !== undefined) { if (key === 'show') { - if ( - value === 'policy_response' || - value === 'details' || - value === 'activity_log' || - value === 'isolate' - ) { - data[key] = value; + if (allowedShowValues.includes(value as EndpointIndexUIQueryParams['show'])) { + data[key] = value as EndpointIndexUIQueryParams['show']; } } else { data[key] = value; @@ -378,3 +382,7 @@ export const getActivityLogError: ( return activityLog.error; } }); + +export const getIsEndpointHostIsolated = createSelector(detailsData, (details) => { + return (details && isEndpointHostIsolated(details)) || false; +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index ac06f98004f597..53ddfaee7aa053 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -114,7 +114,7 @@ export interface EndpointIndexUIQueryParams { /** Which page to show */ page_index?: string; /** show the policy response or host details */ - show?: 'policy_response' | 'activity_log' | 'details' | 'isolate'; + show?: 'policy_response' | 'activity_log' | 'details' | 'isolate' | 'unisolate'; /** Query text from search bar*/ admin_query?: string; } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx new file mode 100644 index 00000000000000..ac1b83bdc493bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/context_menu_item_nav_by_rotuer.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiContextMenuItem, EuiContextMenuItemProps } from '@elastic/eui'; +import { NavigateToAppOptions } from 'kibana/public'; +import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; + +export interface ContextMenuItemNavByRouterProps extends EuiContextMenuItemProps { + navigateAppId: string; + navigateOptions: NavigateToAppOptions; + children: React.ReactNode; +} + +/** + * Just like `EuiContextMenuItem`, but allows for additional props to be defined which will + * allow navigation to a URL path via React Router + */ +export const ContextMenuItemNavByRouter = memo( + ({ navigateAppId, navigateOptions, onClick, children, ...otherMenuItemProps }) => { + const handleOnClick = useNavigateToAppEventHandler(navigateAppId, { + ...navigateOptions, + onClick, + }); + + return ( + + {children} + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx index 8110c5f16a8923..94303c43cd4da3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/table_row_actions.tsx @@ -9,37 +9,31 @@ import React, { memo, useCallback, useMemo, useState } from 'react'; import { EuiButtonIcon, EuiContextMenuPanel, - EuiPopover, - EuiContextMenuItemProps, EuiContextMenuPanelProps, - EuiContextMenuItem, + EuiPopover, EuiPopoverProps, } from '@elastic/eui'; -import { NavigateToAppOptions } from 'kibana/public'; import { i18n } from '@kbn/i18n'; -import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; +import { ContextMenuItemNavByRouter } from './context_menu_item_nav_by_rotuer'; +import { HostMetadata } from '../../../../../../common/endpoint/types'; +import { useEndpointActionItems } from '../hooks'; export interface TableRowActionProps { - items: Array< - Omit & { - navigateAppId: string; - navigateOptions: NavigateToAppOptions; - children: React.ReactNode; - key: string; - } - >; + endpointMetadata: HostMetadata; } -export const TableRowActions = memo(({ items }) => { +export const TableRowActions = memo(({ endpointMetadata }) => { const [isOpen, setIsOpen] = useState(false); + const endpointActions = useEndpointActionItems(endpointMetadata); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); const menuItems: EuiContextMenuPanelProps['items'] = useMemo(() => { - return items.map((itemProps) => { - return ; + return endpointActions.map((itemProps) => { + return ; }); - }, [handleCloseMenu, items]); + }, [handleCloseMenu, endpointActions]); const panelProps: EuiPopoverProps['panelProps'] = useMemo(() => { return { 'data-test-subj': 'tableRowActionsMenuPanel' }; @@ -69,22 +63,4 @@ export const TableRowActions = memo(({ items }) => { }); TableRowActions.displayName = 'EndpointTableRowActions'; -const EuiContextMenuItemNavByRouter = memo< - EuiContextMenuItemProps & { - navigateAppId: string; - navigateOptions: NavigateToAppOptions; - children: React.ReactNode; - } ->(({ navigateAppId, navigateOptions, onClick, children, ...otherMenuItemProps }) => { - const handleOnClick = useNavigateToAppEventHandler(navigateAppId, { - ...navigateOptions, - onClick, - }); - - return ( - - {children} - - ); -}); -EuiContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; +ContextMenuItemNavByRouter.displayName = 'EuiContextMenuItemNavByRouter'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx new file mode 100644 index 00000000000000..7ecbad54dbbece --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.test.tsx @@ -0,0 +1,113 @@ +/* + * 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 { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { ActionsMenu } from './actions_menu'; +import React from 'react'; +import { act } from '@testing-library/react'; +import { endpointPageHttpMock } from '../../../mocks'; +import { fireEvent } from '@testing-library/dom'; + +jest.mock('../../../../../../common/lib/kibana'); + +describe('When using the Endpoint Details Actions Menu', () => { + let render: () => Promise>; + let coreStart: AppContextTestRender['coreStart']; + let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + let renderResult: ReturnType; + let httpMocks: ReturnType; + + const setEndpointMetadataResponse = (isolation: boolean = false) => { + const endpointHost = httpMocks.responseProvider.metadataDetails(); + // Safe to mutate this mocked data + // @ts-ignore + endpointHost.metadata.Endpoint.state.isolation = isolation; + httpMocks.responseProvider.metadataDetails.mockReturnValue(endpointHost); + }; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + (useKibana as jest.Mock).mockReturnValue({ services: mockedContext.startServices }); + coreStart = mockedContext.coreStart; + waitForAction = mockedContext.middlewareSpy.waitForAction; + httpMocks = endpointPageHttpMock(mockedContext.coreStart.http); + + act(() => { + mockedContext.history.push( + '/endpoints?selected_endpoint=5fe11314-678c-413e-87a2-b4a3461878ee' + ); + }); + + render = async () => { + renderResult = mockedContext.render(); + + await act(async () => { + await waitForAction('serverReturnedEndpointDetails'); + }); + + act(() => { + fireEvent.click(renderResult.getByTestId('endpointDetailsActionsButton')); + }); + + return renderResult; + }; + }); + + describe('and endpoint host is NOT isolated', () => { + beforeEach(() => setEndpointMetadataResponse()); + + it.each([ + ['Isolate host', 'isolateLink'], + ['View host details', 'hostLink'], + ['View agent policy', 'agentPolicyLink'], + ['View agent details', 'agentDetailsLink'], + ])('should display %s action', async (_, dataTestSubj) => { + await render(); + expect(renderResult.getByTestId(dataTestSubj)).not.toBeNull(); + }); + + it.each([ + ['Isolate host', 'isolateLink'], + ['View host details', 'hostLink'], + ['View agent policy', 'agentPolicyLink'], + ['View agent details', 'agentDetailsLink'], + ])( + 'should navigate via kibana `navigateToApp()` when %s is clicked', + async (_, dataTestSubj) => { + await render(); + act(() => { + fireEvent.click(renderResult.getByTestId(dataTestSubj)); + }); + + expect(coreStart.application.navigateToApp).toHaveBeenCalled(); + } + ); + }); + + describe('and endpoint host is isolated', () => { + beforeEach(() => setEndpointMetadataResponse(true)); + + it('should display Unisolate action', async () => { + await render(); + expect(renderResult.getByTestId('unIsolateLink')).not.toBeNull(); + }); + + it('should navigate via router when unisolate is clicked', async () => { + await render(); + act(() => { + fireEvent.click(renderResult.getByTestId('unIsolateLink')); + }); + + expect(coreStart.application.navigateToApp).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx new file mode 100644 index 00000000000000..c778f4f2a08ec8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/actions_menu.tsx @@ -0,0 +1,63 @@ +/* + * 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, { useState, useCallback, useMemo } from 'react'; +import { EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useEndpointActionItems, useEndpointSelector } from '../../hooks'; +import { detailsData } from '../../../store/selectors'; +import { ContextMenuItemNavByRouter } from '../../components/context_menu_item_nav_by_rotuer'; + +export const ActionsMenu = React.memo<{}>(() => { + const endpointDetails = useEndpointSelector(detailsData); + const menuOptions = useEndpointActionItems(endpointDetails); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const closePopoverHandler = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const takeActionItems = useMemo(() => { + return menuOptions.map((item) => { + return ; + }); + }, [closePopoverHandler, menuOptions]); + + const takeActionButton = useMemo(() => { + return ( + { + setIsPopoverOpen(!isPopoverOpen); + }} + > + + + ); + }, [isPopoverOpen]); + + return ( + + + + ); +}); + +ActionsMenu.displayName = 'ActionMenu'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx index e299a7ec5f9730..289c1efeab0411 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/endpoint_isolate_flyout_panel.tsx @@ -5,17 +5,19 @@ * 2.0. */ -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import { i18n } from '@kbn/i18n'; +import { EuiForm } from '@elastic/eui'; import { HostMetadata } from '../../../../../../../common/endpoint/types'; import { BackToEndpointDetailsFlyoutSubHeader } from './back_to_endpoint_details_flyout_subheader'; import { EndpointIsolatedFormProps, EndpointIsolateForm, EndpointIsolateSuccess, + EndpointUnisolateForm, } from '../../../../../../common/components/endpoint/host_isolation'; import { FlyoutBodyNoTopPadding } from './flyout_body_no_top_padding'; import { getEndpointDetailsPath } from '../../../../../common/routing'; @@ -25,18 +27,21 @@ import { getIsIsolationRequestPending, getWasIsolationRequestSuccessful, uiQueryParams, + getIsEndpointHostIsolated, } from '../../../store/selectors'; import { AppAction } from '../../../../../../common/store/actions'; -import { useToasts } from '../../../../../../common/lib/kibana'; -export const EndpointIsolateFlyoutPanel = memo<{ +/** + * Component handles both isolate and un-isolate for a given endpoint + */ +export const EndpointIsolationFlyoutPanel = memo<{ hostMeta: HostMetadata; }>(({ hostMeta }) => { const history = useHistory(); const dispatch = useDispatch>(); - const toast = useToasts(); const { show, ...queryParams } = useEndpointSelector(uiQueryParams); + const isCurrentlyIsolated = useEndpointSelector(getIsEndpointHostIsolated); const isPending = useEndpointSelector(getIsIsolationRequestPending); const wasSuccessful = useEndpointSelector(getWasIsolationRequestSuccessful); const isolateError = useEndpointSelector(getIsolationRequestError); @@ -45,6 +50,8 @@ export const EndpointIsolateFlyoutPanel = memo<{ Parameters[0] >({ comment: '' }); + const IsolationForm = isCurrentlyIsolated ? EndpointUnisolateForm : EndpointIsolateForm; + const handleCancel: EndpointIsolatedFormProps['onCancel'] = useCallback(() => { history.push( getEndpointDetailsPath({ @@ -59,11 +66,14 @@ export const EndpointIsolateFlyoutPanel = memo<{ dispatch({ type: 'endpointIsolationRequest', payload: { - endpoint_ids: [hostMeta.agent.id], - comment: formValues.comment, + type: isCurrentlyIsolated ? 'unisolate' : 'isolate', + data: { + endpoint_ids: [hostMeta.agent.id], + comment: formValues.comment, + }, }, }); - }, [dispatch, formValues.comment, hostMeta.agent.id]); + }, [dispatch, formValues.comment, hostMeta.agent.id, isCurrentlyIsolated]); const handleChange: EndpointIsolatedFormProps['onChange'] = useCallback((changes) => { setFormValues((prevState) => { @@ -74,12 +84,6 @@ export const EndpointIsolateFlyoutPanel = memo<{ }); }, []); - useEffect(() => { - if (isolateError) { - toast.addDanger(isolateError.message); - } - }, [isolateError, toast]); - return ( <> @@ -88,6 +92,7 @@ export const EndpointIsolateFlyoutPanel = memo<{ {wasSuccessful ? ( ) : ( - + + + )} ); }); -EndpointIsolateFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel'; +EndpointIsolationFlyoutPanel.displayName = 'EndpointIsolateFlyoutPanel'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 8d985f3a4cfe27..89c0e3e6a3e067 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -11,6 +11,7 @@ import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, + EuiFlyoutFooter, EuiLoadingContent, EuiTitle, EuiText, @@ -55,10 +56,11 @@ import { } from './components/endpoint_details_tabs'; import { PreferenceFormattedDateFromPrimitive } from '../../../../../common/components/formatted_date'; -import { EndpointIsolateFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; +import { EndpointIsolationFlyoutPanel } from './components/endpoint_isolate_flyout_panel'; import { BackToEndpointDetailsFlyoutSubHeader } from './components/back_to_endpoint_details_flyout_subheader'; import { FlyoutBodyNoTopPadding } from './components/flyout_body_no_top_padding'; import { getEndpointListPath } from '../../../../common/routing'; +import { ActionsMenu } from './components/actions_menu'; const DetailsFlyoutBody = styled(EuiFlyoutBody)` overflow-y: hidden; @@ -128,6 +130,9 @@ export const EndpointDetailsFlyout = memo(() => { }, ]; + const showFlyoutFooter = + show === 'details' || show === 'policy_response' || show === 'activity_log'; + const handleFlyoutClose = useCallback(() => { const { show: _show, ...urlSearchParams } = queryParamsWithoutSelectedEndpoint; history.push( @@ -203,7 +208,15 @@ export const EndpointDetailsFlyout = memo(() => { {show === 'policy_response' && } - {show === 'isolate' && } + {(show === 'isolate' || show === 'unisolate') && ( + + )} + + {showFlyoutFooter && ( + + + + )} )} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts similarity index 89% rename from x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts rename to x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts index 5c8de0c4e0f3bc..4c00c00e50dbc2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/hooks.ts @@ -7,13 +7,14 @@ import { useSelector } from 'react-redux'; import { useMemo } from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; -import { EndpointState } from '../types'; +import { EndpointState } from '../../types'; +import { State } from '../../../../../common/store'; import { MANAGEMENT_STORE_ENDPOINTS_NAMESPACE, MANAGEMENT_STORE_GLOBAL_NAMESPACE, -} from '../../../common/constants'; -import { State } from '../../../../common/store'; +} from '../../../../common/constants'; +import { useKibana } from '../../../../../common/lib/kibana'; + export function useEndpointSelector(selector: (state: EndpointState) => TSelected) { return useSelector(function (state: State) { return selector( @@ -38,7 +39,6 @@ export const useIngestUrl = (subpath: string): { url: string; appId: string; app }; }, [services.application, subpath]); }; - /** * Returns an object that contains Fleet app and URL information */ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts new file mode 100644 index 00000000000000..a5a22b43e63d11 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './hooks'; +export * from './use_endpoint_action_items'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx new file mode 100644 index 00000000000000..dd498ffbbcacca --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { MANAGEMENT_APP_ID } from '../../../../common/constants'; +import { APP_ID, SecurityPageName } from '../../../../../../common/constants'; +import { pagePathGetters } from '../../../../../../../fleet/public'; +import { getEndpointDetailsPath } from '../../../../common/routing'; +import { HostMetadata, MaybeImmutable } from '../../../../../../common/endpoint/types'; +import { useFormatUrl } from '../../../../../common/components/link_to'; +import { useEndpointSelector } from './hooks'; +import { agentPolicies, uiQueryParams } from '../../store/selectors'; +import { useKibana } from '../../../../../common/lib/kibana'; +import { ContextMenuItemNavByRouterProps } from '../components/context_menu_item_nav_by_rotuer'; +import { isEndpointHostIsolated } from '../../../../../common/utils/validators/is_endpoint_host_isolated'; + +/** + * Returns a list (array) of actions for an individual endpoint + * @param endpointMetadata + */ +export const useEndpointActionItems = ( + endpointMetadata: MaybeImmutable | undefined +): ContextMenuItemNavByRouterProps[] => { + const { formatUrl } = useFormatUrl(SecurityPageName.administration); + const fleetAgentPolicies = useEndpointSelector(agentPolicies); + const allCurrentUrlParams = useEndpointSelector(uiQueryParams); + const { + services: { + application: { getUrlForApp }, + }, + } = useKibana(); + + return useMemo(() => { + if (endpointMetadata) { + const isIsolated = isEndpointHostIsolated(endpointMetadata); + const endpointId = endpointMetadata.agent.id; + const endpointPolicyId = endpointMetadata.Endpoint.policy.applied.id; + const endpointHostName = endpointMetadata.host.hostname; + const fleetAgentId = endpointMetadata.elastic.agent.id; + const { + show, + selected_endpoint: _selectedEndpoint, + ...currentUrlParams + } = allCurrentUrlParams; + const endpointIsolatePath = getEndpointDetailsPath({ + name: 'endpointIsolate', + ...currentUrlParams, + selected_endpoint: endpointId, + }); + const endpointUnIsolatePath = getEndpointDetailsPath({ + name: 'endpointUnIsolate', + ...currentUrlParams, + selected_endpoint: endpointId, + }); + + return [ + isIsolated + ? { + 'data-test-subj': 'unIsolateLink', + icon: 'logoSecurity', + key: 'unIsolateHost', + navigateAppId: MANAGEMENT_APP_ID, + navigateOptions: { + path: endpointUnIsolatePath, + }, + href: formatUrl(endpointUnIsolatePath), + children: ( + + ), + } + : { + 'data-test-subj': 'isolateLink', + icon: 'logoSecurity', + key: 'isolateHost', + navigateAppId: MANAGEMENT_APP_ID, + navigateOptions: { + path: endpointIsolatePath, + }, + href: formatUrl(endpointIsolatePath), + children: ( + + ), + }, + { + 'data-test-subj': 'hostLink', + icon: 'logoSecurity', + key: 'hostDetailsLink', + navigateAppId: APP_ID, + navigateOptions: { path: `hosts/${endpointHostName}` }, + href: `${getUrlForApp('securitySolution')}/hosts/${endpointHostName}`, + children: ( + + ), + }, + { + icon: 'gear', + key: 'agentConfigLink', + 'data-test-subj': 'agentPolicyLink', + navigateAppId: 'fleet', + navigateOptions: { + path: `#${pagePathGetters.policy_details({ + policyId: fleetAgentPolicies[endpointPolicyId], + })}`, + }, + href: `${getUrlForApp('fleet')}#${pagePathGetters.policy_details({ + policyId: fleetAgentPolicies[endpointPolicyId], + })}`, + disabled: fleetAgentPolicies[endpointPolicyId] === undefined, + children: ( + + ), + }, + { + icon: 'gear', + key: 'agentDetailsLink', + 'data-test-subj': 'agentDetailsLink', + navigateAppId: 'fleet', + navigateOptions: { + path: `#${pagePathGetters.fleet_agent_details({ + agentId: fleetAgentId, + })}`, + }, + href: `${getUrlForApp('fleet')}#${pagePathGetters.fleet_agent_details({ + agentId: fleetAgentId, + })}`, + children: ( + + ), + }, + ]; + } + + return []; + }, [allCurrentUrlParams, endpointMetadata, fleetAgentPolicies, formatUrl, getUrlForApp]); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index d963682ff005d1..509bb7b4cf7111 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -522,7 +522,7 @@ describe('when on the endpoint list page', () => { const { // eslint-disable-next-line @typescript-eslint/naming-convention host_status, - metadata: { agent, ...details }, + metadata: { agent, Endpoint, ...details }, // eslint-disable-next-line @typescript-eslint/naming-convention query_strategy_version, } = mockEndpointDetailsApiResult(); @@ -531,6 +531,13 @@ describe('when on the endpoint list page', () => { host_status, metadata: { ...details, + Endpoint: { + ...Endpoint, + state: { + ...Endpoint.state, + isolation: false, + }, + }, agent: { ...agent, id: '1', @@ -633,11 +640,10 @@ describe('when on the endpoint list page', () => { jest.clearAllMocks(); }); - it('should show the flyout', async () => { + it('should show the flyout and footer', async () => { const renderResult = await renderAndWaitForData(); - return renderResult.findByTestId('endpointDetailsFlyout').then((flyout) => { - expect(flyout).not.toBeNull(); - }); + await expect(renderResult.findByTestId('endpointDetailsFlyout')).not.toBeNull(); + await expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).not.toBeNull(); }); it('should display policy name value as a link', async () => { @@ -743,6 +749,11 @@ describe('when on the endpoint list page', () => { ); }); + it('should show the Take Action button', async () => { + const renderResult = await renderAndWaitForData(); + expect(renderResult.getByTestId('endpointDetailsActionsButton')).not.toBeNull(); + }); + describe('when link to reassignment in Ingest is clicked', () => { beforeEach(async () => { coreStart.application.getUrlForApp.mockReturnValue('/app/fleet'); @@ -993,18 +1004,13 @@ describe('when on the endpoint list page', () => { }); }); - it('should show error toast if isolate fails', async () => { + it('should show error if isolate fails', async () => { isolateApiMock.responseProvider.isolateHost.mockImplementation(() => { throw new Error('oh oh. something went wrong'); }); - - // coreStart.http.post.mockReset(); - // coreStart.http.post.mockRejectedValue(new Error('oh oh. something went wrong')); await confirmIsolateAndWaitForApiResponse('failure'); - expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'oh oh. something went wrong' - ); + expect(renderResult.getByText('oh oh. something went wrong')).not.toBeNull(); }); it('should reset isolation state and show form again', async () => { @@ -1031,6 +1037,10 @@ describe('when on the endpoint list page', () => { ) ).toBe(true); }); + + it('should NOT show the flyout footer', async () => { + await expect(renderResult.queryByTestId('endpointDetailsFlyoutFooter')).toBeNull(); + }); }); }); @@ -1045,9 +1055,19 @@ describe('when on the endpoint list page', () => { const { hosts, query_strategy_version: queryStrategyVersion } = mockEndpointResultList(); hostInfo = { host_status: hosts[0].host_status, - metadata: hosts[0].metadata, + metadata: { + ...hosts[0].metadata, + Endpoint: { + ...hosts[0].metadata.Endpoint, + state: { + ...hosts[0].metadata.Endpoint.state, + isolation: false, + }, + }, + }, query_strategy_version: queryStrategyVersion, }; + const packagePolicy = docGenerator.generatePolicyPackagePolicy(); packagePolicy.id = hosts[0].metadata.Endpoint.policy.applied.id; const agentPolicy = generator.generateAgentPolicy(); @@ -1098,6 +1118,8 @@ describe('when on the endpoint list page', () => { expect(isolateLink.getAttribute('href')).toEqual( getEndpointDetailsPath({ name: 'endpointIsolate', + page_index: '0', + page_size: '10', selected_endpoint: hostInfo.metadata.agent.id, }) ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 7e5658f7b0cba0..cef6acff4e3442 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -39,11 +39,7 @@ import { import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { CreateStructuredSelector } from '../../../../common/store'; import { Immutable, HostInfo } from '../../../../../common/endpoint/types'; -import { - DEFAULT_POLL_INTERVAL, - MANAGEMENT_APP_ID, - MANAGEMENT_PAGE_SIZE_OPTIONS, -} from '../../../common/constants'; +import { DEFAULT_POLL_INTERVAL, MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants'; import { PolicyEmptyState, HostsEmptyState } from '../../../components/management_empty_state'; import { FormattedDate } from '../../../../common/components/formatted_date'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; @@ -61,7 +57,6 @@ import { OutOfDate } from './components/out_of_date'; import { AdminSearchBar } from './components/search_bar'; import { AdministrationListPage } from '../../../components/administration_list_page'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { APP_ID } from '../../../../../common/constants'; import { LinkToApp } from '../../../../common/components/endpoint/link_to_app'; import { TableRowActions } from './components/table_row_actions'; @@ -120,7 +115,6 @@ export const EndpointList = () => { policyItemsLoading, endpointPackageVersion, endpointsExist, - agentPolicies, autoRefreshInterval, isAutoRefreshEnabled, patternsError, @@ -130,7 +124,6 @@ export const EndpointList = () => { isTransformEnabled, } = useEndpointSelector(selector); const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); - const dispatch = useDispatch<(a: EndpointAction) => void>(); // cap ability to page at 10k records. (max_result_window) const maxPageCount = totalItemCount > MAX_PAGINATED_ITEM ? MAX_PAGINATED_ITEM : totalItemCount; @@ -427,102 +420,15 @@ export const EndpointList = () => { }), actions: [ { + // eslint-disable-next-line react/display-name render: (item: HostInfo) => { - const endpointIsolatePath = getEndpointDetailsPath({ - name: 'endpointIsolate', - selected_endpoint: item.metadata.agent.id, - }); - - return ( - - ), - }, - { - 'data-test-subj': 'hostLink', - icon: 'logoSecurity', - key: 'hostDetailsLink', - navigateAppId: APP_ID, - navigateOptions: { path: `hosts/${item.metadata.host.hostname}` }, - href: `${services?.application?.getUrlForApp('securitySolution')}/hosts/${ - item.metadata.host.hostname - }`, - children: ( - - ), - }, - { - icon: 'logoObservability', - key: 'agentConfigLink', - 'data-test-subj': 'agentPolicyLink', - navigateAppId: 'fleet', - navigateOptions: { - path: `#${pagePathGetters.policy_details({ - policyId: agentPolicies[item.metadata.Endpoint.policy.applied.id], - })}`, - }, - href: `${services?.application?.getUrlForApp( - 'fleet' - )}#${pagePathGetters.policy_details({ - policyId: agentPolicies[item.metadata.Endpoint.policy.applied.id], - })}`, - disabled: - agentPolicies[item.metadata.Endpoint.policy.applied.id] === undefined, - children: ( - - ), - }, - { - icon: 'logoObservability', - key: 'agentDetailsLink', - 'data-test-subj': 'agentDetailsLink', - navigateAppId: 'fleet', - navigateOptions: { - path: `#${pagePathGetters.fleet_agent_details({ - agentId: item.metadata.elastic.agent.id, - })}`, - }, - href: `${services?.application?.getUrlForApp( - 'fleet' - )}#${pagePathGetters.fleet_agent_details({ - agentId: item.metadata.elastic.agent.id, - })}`, - children: ( - - ), - }, - ]} - /> - ); + return ; }, }, ], }, ]; - }, [queryParams, search, formatUrl, PAD_LEFT, services?.application, agentPolicies]); + }, [queryParams, search, formatUrl, PAD_LEFT]); const renderTableOrEmptyState = useMemo(() => { if (endpointsExist || areEndpointsEnrolling) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4c927d5094ca4c..982cf768db0786 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20313,9 +20313,6 @@ "xpack.securitySolution.endpoint.ingestToastTitle": "アプリを初期化できませんでした", "xpack.securitySolution.endpoint.list.actionmenu": "開く", "xpack.securitySolution.endpoint.list.actions": "アクション", - "xpack.securitySolution.endpoint.list.actions.agentDetails": "エージェント詳細を表示", - "xpack.securitySolution.endpoint.list.actions.agentPolicy": "エージェントポリシーを表示", - "xpack.securitySolution.endpoint.list.actions.hostDetails": "ホスト詳細を表示", "xpack.securitySolution.endpoint.list.endpointsEnrolling": "エンドポイントを登録しています。進行状況を追跡するには、{agentsLink}してください。", "xpack.securitySolution.endpoint.list.endpointsEnrolling.viewAgentsLink": "エージェントを表示", "xpack.securitySolution.endpoint.list.endpointVersion": "バージョン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 57a1b6a8751fd4..46f08cbed6c8e7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20613,9 +20613,6 @@ "xpack.securitySolution.endpoint.ingestToastTitle": "应用无法初始化", "xpack.securitySolution.endpoint.list.actionmenu": "未结", "xpack.securitySolution.endpoint.list.actions": "操作", - "xpack.securitySolution.endpoint.list.actions.agentDetails": "查看代理详情", - "xpack.securitySolution.endpoint.list.actions.agentPolicy": "查看代理策略", - "xpack.securitySolution.endpoint.list.actions.hostDetails": "查看主机详情", "xpack.securitySolution.endpoint.list.endpointsEnrolling": "正在注册终端。{agentsLink}以跟踪进度。", "xpack.securitySolution.endpoint.list.endpointsEnrolling.viewAgentsLink": "查看代理", "xpack.securitySolution.endpoint.list.endpointVersion": "版本", diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json index b70a9d5df0eb88..22f4afcf99d4d9 100644 --- a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json +++ b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json @@ -13,7 +13,13 @@ "status": "failure" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "3838df35-a095-4af4-8fce-0b6d78793f2e", @@ -81,7 +87,13 @@ "status": "failure" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "963b081e-60d1-482c-befd-a5815fa8290f", @@ -152,7 +164,13 @@ "status": "success" } }, - "status": "enrolled" + "status": "enrolled", + "configuration": { + "isolation": false + }, + "state": { + "isolation": false + } }, "agent": { "id": "b3412d6f-b022-4448-8fee-21cc936ea86b", From b130edfdb49506b694e2c4b00674fe5869921cc6 Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Fri, 4 Jun 2021 17:29:45 +0200 Subject: [PATCH 69/77] [Lens] fix suggestions for filters and filter by (#101372) * [Lens] fix suggestions for filters and filter by * Update advanced_options.tsx revert another PR changes --- .../dimension_panel/filtering.tsx | 2 +- .../filters/filter_popover.test.tsx | 23 +++++++++++++++---- .../definitions/filters/filter_popover.tsx | 2 +- .../indexpattern_datasource/query_input.tsx | 7 +++--- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx index 65bc23b4eb1cad..68705ebf2d1578 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/filtering.tsx @@ -114,7 +114,7 @@ export function Filtering({ } > { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx index 6d1cc3254ca7e8..1c2e64735ca16b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.test.tsx @@ -13,6 +13,7 @@ import { createMockedIndexPattern } from '../../../mocks'; import { FilterPopover } from './filter_popover'; import { LabelInput } from '../shared_components'; import { QueryInput } from '../../../query_input'; +import { QueryStringInput } from '../../../../../../../../src/plugins/data/public'; jest.mock('.', () => ({ isQueryValid: () => true, @@ -32,13 +33,25 @@ const defaultProps = { ), initiallyOpen: true, }; +jest.mock('../../../../../../../../src/plugins/data/public', () => ({ + QueryStringInput: () => { + return 'QueryStringInput'; + }, +})); describe('filter popover', () => { - jest.mock('../../../../../../../../src/plugins/data/public', () => ({ - QueryStringInput: () => { - return 'QueryStringInput'; - }, - })); + it('passes correct props to QueryStringInput', () => { + const instance = mount(); + instance.update(); + expect(instance.find(QueryStringInput).props()).toEqual( + expect.objectContaining({ + dataTestSubj: 'indexPattern-filters-queryStringInput', + indexPatterns: ['my-fake-index-pattern'], + isInvalid: false, + query: { language: 'kuery', query: 'bytes >= 1' }, + }) + ); + }); it('should be open if is open by creation', () => { const instance = mount(); instance.update(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx index f5428bf24348f6..bfb0cffece57c4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx @@ -75,7 +75,7 @@ export const FilterPopover = ({ { if (inputRef.current) inputRef.current.focus(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx index 6c2b62f96eaec3..a67199a9d34325 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/query_input.tsx @@ -7,21 +7,20 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { IndexPattern } from './types'; import { QueryStringInput, Query } from '../../../../../src/plugins/data/public'; import { useDebouncedValue } from '../shared_components'; export const QueryInput = ({ value, onChange, - indexPattern, + indexPatternTitle, isInvalid, onSubmit, disableAutoFocus, }: { value: Query; onChange: (input: Query) => void; - indexPattern: IndexPattern; + indexPatternTitle: string; isInvalid: boolean; onSubmit: () => void; disableAutoFocus?: boolean; @@ -35,7 +34,7 @@ export const QueryInput = ({ disableAutoFocus={disableAutoFocus} isInvalid={isInvalid} bubbleSubmitEvent={false} - indexPatterns={[indexPattern]} + indexPatterns={[indexPatternTitle]} query={inputValue} onChange={handleInputChange} onSubmit={() => { From 03bc6bfe311735334f62fa4fe4053469e8e2c1f2 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Fri, 4 Jun 2021 11:45:06 -0400 Subject: [PATCH 70/77] [Upgrade Assistant] Use config for readonly mode (#101296) --- .../client_integration/helpers/setup_environment.tsx | 4 ++-- x-pack/plugins/upgrade_assistant/common/config.ts | 6 ++++++ x-pack/plugins/upgrade_assistant/common/constants.ts | 7 ------- .../public/application/mount_management_section.ts | 6 +++--- x-pack/plugins/upgrade_assistant/public/plugin.ts | 5 +++-- x-pack/plugins/upgrade_assistant/server/index.ts | 5 +++-- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx index 31189428fda18f..faeb0e4a40abd4 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/setup_environment.tsx @@ -17,7 +17,7 @@ import { } from 'src/core/public/mocks'; import { HttpSetup } from 'src/core/public'; -import { mockKibanaSemverVersion, UA_READONLY_MODE } from '../../../common/constants'; +import { mockKibanaSemverVersion } from '../../../common/constants'; import { AppContextProvider } from '../../../public/application/app_context'; import { apiService } from '../../../public/application/lib/api'; import { breadcrumbService } from '../../../public/application/lib/breadcrumbs'; @@ -40,7 +40,7 @@ export const WithAppDependencies = (Comp: any, overrides: Record; diff --git a/x-pack/plugins/upgrade_assistant/common/constants.ts b/x-pack/plugins/upgrade_assistant/common/constants.ts index 29253d3c373d6c..bab3d8c3fda866 100644 --- a/x-pack/plugins/upgrade_assistant/common/constants.ts +++ b/x-pack/plugins/upgrade_assistant/common/constants.ts @@ -14,13 +14,6 @@ import SemVer from 'semver/classes/semver'; export const mockKibanaVersion = '8.0.0'; export const mockKibanaSemverVersion = new SemVer(mockKibanaVersion); -/* - * This will be set to true up until the last minor before the next major. - * In readonly mode, the user will not be able to perform any actions in the UI - * and will be presented with a message indicating as such. - */ -export const UA_READONLY_MODE = true; - /* * Map of 7.0 --> 8.0 index setting deprecation log messages and associated settings * We currently only support one setting deprecation (translog retention), but the code is written diff --git a/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts b/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts index b17c1301f83f30..73e5d33e6c968e 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/mount_management_section.ts @@ -7,7 +7,6 @@ import { CoreSetup } from 'src/core/public'; import { ManagementAppMountParams } from '../../../../../src/plugins/management/public'; -import { UA_READONLY_MODE } from '../../common/constants'; import { renderApp } from './render_app'; import { KibanaVersionContext } from './app_context'; import { apiService } from './lib/api'; @@ -17,7 +16,8 @@ export async function mountManagementSection( coreSetup: CoreSetup, isCloudEnabled: boolean, params: ManagementAppMountParams, - kibanaVersionInfo: KibanaVersionContext + kibanaVersionInfo: KibanaVersionContext, + readonly: boolean ) { const [ { i18n, docLinks, notifications, application, deprecations }, @@ -37,7 +37,7 @@ export async function mountManagementSection( docLinks, kibanaVersionInfo, notifications, - isReadOnlyMode: UA_READONLY_MODE, + isReadOnlyMode: readonly, history, api: apiService, breadcrumbs: breadcrumbService, diff --git a/x-pack/plugins/upgrade_assistant/public/plugin.ts b/x-pack/plugins/upgrade_assistant/public/plugin.ts index e33f146dd47fcf..4f5429201f3041 100644 --- a/x-pack/plugins/upgrade_assistant/public/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/public/plugin.ts @@ -22,7 +22,7 @@ interface Dependencies { export class UpgradeAssistantUIPlugin implements Plugin { constructor(private ctx: PluginInitializerContext) {} setup(coreSetup: CoreSetup, { cloud, management }: Dependencies) { - const { enabled } = this.ctx.config.get(); + const { enabled, readonly } = this.ctx.config.get(); if (!enabled) { return; @@ -61,7 +61,8 @@ export class UpgradeAssistantUIPlugin implements Plugin { coreSetup, isCloudEnabled, params, - kibanaVersionInfo + kibanaVersionInfo, + readonly ); return () => { diff --git a/x-pack/plugins/upgrade_assistant/server/index.ts b/x-pack/plugins/upgrade_assistant/server/index.ts index 15e14356724077..035a6515de1529 100644 --- a/x-pack/plugins/upgrade_assistant/server/index.ts +++ b/x-pack/plugins/upgrade_assistant/server/index.ts @@ -7,15 +7,16 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; import { UpgradeAssistantServerPlugin } from './plugin'; -import { configSchema } from '../common/config'; +import { configSchema, Config } from '../common/config'; export const plugin = (ctx: PluginInitializerContext) => { return new UpgradeAssistantServerPlugin(ctx); }; -export const config: PluginConfigDescriptor = { +export const config: PluginConfigDescriptor = { schema: configSchema, exposeToBrowser: { enabled: true, + readonly: true, }, }; From bc363181cf15a4e9462552d784bbfa131a1f3580 Mon Sep 17 00:00:00 2001 From: igoristic Date: Fri, 4 Jun 2021 11:52:27 -0400 Subject: [PATCH 71/77] Allow . system indices in regex (#100831) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/es_glob_patterns.test.ts | 120 ++++++++++++++++++ .../server/alerts/large_shard_size_alert.ts | 2 +- .../lib/alerts/fetch_index_shard_size.ts | 6 +- 3 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/monitoring/common/es_glob_patterns.test.ts diff --git a/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts b/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts new file mode 100644 index 00000000000000..64250d0b3c5aea --- /dev/null +++ b/x-pack/plugins/monitoring/common/es_glob_patterns.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { ESGlobPatterns } from './es_glob_patterns'; + +const testIndices = [ + '.kibana_task_manager_inifc_1', + '.kibana_shahzad_4', + '.kibana_shahzad_3', + '.kibana_shahzad_2', + '.kibana_shahzad_1', + '.kibana_task_manager_cmarcondes-24_8.0.0_001', + '.kibana_task_manager_custom_kbn-pr93025_8.0.0_001', + '.kibana_task_manager_spong_8.0.0_001', + '.ds-metrics-system.process.summary-default-2021.05.25-00000', + '.kibana_shahzad_9', + '.kibana-felix-log-stream_8.0.0_001', + '.kibana_smith_alerts-observability-apm-000001', + '.ds-logs-endpoint.events.process-default-2021.05.26-000001', + '.kibana_dominiqueclarke54_8.0.0_001', + '.kibana-cmarcondes-19_8.0.0_001', + '.kibana_task_manager_cmarcondes-17_8.0.0_001', + '.kibana_task_manager_jrhodes_8.0.0_001', + '.kibana_task_manager_dominiqueclarke7_8', + 'data_prod_0', + 'data_prod_1', + 'data_prod_2', + 'data_prod_3', + 'filebeat-8.0.0-2021.04.13-000001', + '.kibana_dominiqueclarke55-alerts-8.0.0-000001', + '.ds-metrics-system.socket_summary-default-2021.05.12-000001', + '.kibana_task_manager_dominiqueclarke24_8.0.0_001', + '.kibana_custom_kbn-pr94906_8.0.0_001', + '.kibana_task_manager_cmarcondes-22_8.0.0_001', + '.kibana_dominiqueclarke49-event-log-8.0.0-000001', + 'data_stage_2', + 'data_stage_3', +].sort(); + +const noSystemIndices = [ + 'data_prod_0', + 'data_prod_1', + 'data_prod_2', + 'data_prod_3', + 'filebeat-8.0.0-2021.04.13-000001', + 'data_stage_2', + 'data_stage_3', +].sort(); + +const onlySystemIndices = [ + '.kibana_task_manager_inifc_1', + '.kibana_shahzad_4', + '.kibana_shahzad_3', + '.kibana_shahzad_2', + '.kibana_shahzad_1', + '.kibana_task_manager_cmarcondes-24_8.0.0_001', + '.kibana_task_manager_custom_kbn-pr93025_8.0.0_001', + '.kibana_task_manager_spong_8.0.0_001', + '.ds-metrics-system.process.summary-default-2021.05.25-00000', + '.kibana_shahzad_9', + '.kibana-felix-log-stream_8.0.0_001', + '.kibana_smith_alerts-observability-apm-000001', + '.ds-logs-endpoint.events.process-default-2021.05.26-000001', + '.kibana_dominiqueclarke54_8.0.0_001', + '.kibana-cmarcondes-19_8.0.0_001', + '.kibana_task_manager_cmarcondes-17_8.0.0_001', + '.kibana_task_manager_jrhodes_8.0.0_001', + '.kibana_task_manager_dominiqueclarke7_8', + '.kibana_dominiqueclarke55-alerts-8.0.0-000001', + '.ds-metrics-system.socket_summary-default-2021.05.12-000001', + '.kibana_task_manager_dominiqueclarke24_8.0.0_001', + '.kibana_custom_kbn-pr94906_8.0.0_001', + '.kibana_task_manager_cmarcondes-22_8.0.0_001', + '.kibana_dominiqueclarke49-event-log-8.0.0-000001', +].sort(); + +const kibanaNoTaskIndices = [ + '.kibana_shahzad_4', + '.kibana_shahzad_3', + '.kibana_shahzad_2', + '.kibana_shahzad_1', + '.kibana_shahzad_9', + '.kibana-felix-log-stream_8.0.0_001', + '.kibana_smith_alerts-observability-apm-000001', + '.kibana_dominiqueclarke54_8.0.0_001', + '.kibana-cmarcondes-19_8.0.0_001', + '.kibana_dominiqueclarke55-alerts-8.0.0-000001', + '.kibana_custom_kbn-pr94906_8.0.0_001', + '.kibana_dominiqueclarke49-event-log-8.0.0-000001', +].sort(); + +describe('ES glob index patterns', () => { + it('should exclude system/internal indices', () => { + const validIndexPatterns = ESGlobPatterns.createRegExPatterns('-.*'); + const validIndices = testIndices.filter((index) => + ESGlobPatterns.isValid(index, validIndexPatterns) + ); + expect(validIndices.sort()).toEqual(noSystemIndices); + }); + + it('should only show ".index" system indices', () => { + const validIndexPatterns = ESGlobPatterns.createRegExPatterns('.*'); + const validIndices = testIndices.filter((index) => + ESGlobPatterns.isValid(index, validIndexPatterns) + ); + expect(validIndices.sort()).toEqual(onlySystemIndices); + }); + + it('should only show ".kibana*" indices without _task_', () => { + const validIndexPatterns = ESGlobPatterns.createRegExPatterns('.kibana*,-*_task_*'); + const validIndices = testIndices.filter((index) => + ESGlobPatterns.isValid(index, validIndexPatterns) + ); + expect(validIndices.sort()).toEqual(kibanaNoTaskIndices); + }); +}); diff --git a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts index db318d7962beb9..a6a101bc42afa4 100644 --- a/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/large_shard_size_alert.ts @@ -42,7 +42,7 @@ export class LargeShardSizeAlert extends BaseAlert { id: ALERT_LARGE_SHARD_SIZE, name: ALERT_DETAILS[ALERT_LARGE_SHARD_SIZE].label, throttle: '12h', - defaultParams: { indexPattern: '*', threshold: 55 }, + defaultParams: { indexPattern: '-.*', threshold: 55 }, actionVariables: [ { name: 'shardIndices', diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts index e1da45ab7d9915..aab3f0101ef839 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts @@ -120,11 +120,7 @@ export async function fetchIndexShardSize( for (const indexBucket of indexBuckets) { const shardIndex = indexBucket.key; const topHit = indexBucket.hits?.hits?.hits[0] as TopHitType; - if ( - !topHit || - shardIndex.charAt() === '.' || - !ESGlobPatterns.isValid(shardIndex, validIndexPatterns) - ) { + if (!topHit || !ESGlobPatterns.isValid(shardIndex, validIndexPatterns)) { continue; } const { From 76105cc4d02032ad66490d1a8fd044fd6e84c82e Mon Sep 17 00:00:00 2001 From: Shahzad Date: Fri, 4 Jun 2021 18:04:40 +0200 Subject: [PATCH 72/77] [User Experience] Move ux app to new nav (#101005) --- .github/CODEOWNERS | 2 +- .../plugins/apm/public/application/index.tsx | 1 + .../application/{csmApp.tsx => uxApp.tsx} | 40 ++++++++------- .../RumDashboard/CsmSharedContext/index.tsx | 2 +- .../app/RumDashboard/Panels/MainFilters.tsx | 26 ++-------- .../components/app/RumDashboard/RumHome.tsx | 51 ++++++++++++++----- .../components/app/RumDashboard/index.tsx | 25 ++++----- .../context/apm_plugin/apm_plugin_context.tsx | 2 + x-pack/plugins/apm/public/plugin.ts | 24 ++++++++- .../public/hooks/use_kibana_ui_settings.tsx | 8 +-- x-pack/plugins/observability/public/index.ts | 1 + .../plugins/uptime/public/apps/uptime_app.tsx | 19 ++----- 12 files changed, 112 insertions(+), 89 deletions(-) rename x-pack/plugins/apm/public/application/{csmApp.tsx => uxApp.tsx} (85%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 68fadd4958cbab..725708e8a8af2c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -89,7 +89,7 @@ # Client Side Monitoring / Uptime (lives in APM directories but owned by Uptime) /x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm @elastic/uptime /x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature @elastic/uptime -/x-pack/plugins/apm/public/application/csmApp.tsx @elastic/uptime +/x-pack/plugins/apm/public/application/uxApp.tsx @elastic/uptime /x-pack/plugins/apm/public/components/app/RumDashboard @elastic/uptime /x-pack/plugins/apm/server/lib/rum_client @elastic/uptime /x-pack/plugins/apm/server/routes/rum_client.ts @elastic/uptime diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index 9b8d3c7822d3d9..d5d77eea8c9c0f 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -43,6 +43,7 @@ export const renderApp = ({ config, core: coreStart, plugins: pluginsSetup, + observability: pluginsStart.observability, observabilityRuleTypeRegistry, }; diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/uxApp.tsx similarity index 85% rename from x-pack/plugins/apm/public/application/csmApp.tsx rename to x-pack/plugins/apm/public/application/uxApp.tsx index ca4f4856894f9e..947ff404a14372 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/uxApp.tsx @@ -12,8 +12,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Route, Router } from 'react-router-dom'; import { DefaultTheme, ThemeProvider } from 'styled-components'; +import { i18n } from '@kbn/i18n'; import type { ObservabilityRuleTypeRegistry } from '../../../observability/public'; -import { euiStyled } from '../../../../../src/plugins/kibana_react/common'; import { KibanaContextProvider, RedirectAppLinks, @@ -24,21 +24,16 @@ import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPat import { RumHome, UX_LABEL } from '../components/app/RumDashboard/RumHome'; import { ApmPluginContext } from '../context/apm_plugin/apm_plugin_context'; import { UrlParamsProvider } from '../context/url_params_context/url_params_context'; -import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { ConfigSchema } from '../index'; import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; -import { px, units } from '../style/variables'; import { createStaticIndexPattern } from '../services/rest/index_pattern'; import { UXActionMenu } from '../components/app/RumDashboard/ActionMenu'; import { redirectTo } from '../components/routing/redirect_to'; +import { useBreadcrumbs } from '../../../observability/public'; +import { useApmPluginContext } from '../context/apm_plugin/use_apm_plugin_context'; -const CsmMainContainer = euiStyled.div` - padding: ${px(units.plus)}; - height: 100%; -`; - -export const rumRoutes: APMRouteDefinition[] = [ +export const uxRoutes: APMRouteDefinition[] = [ { exact: true, path: '/', @@ -47,10 +42,20 @@ export const rumRoutes: APMRouteDefinition[] = [ }, ]; -function CsmApp() { +function UxApp() { const [darkMode] = useUiSetting$('theme:darkMode'); - useBreadcrumbs(rumRoutes); + const { core } = useApmPluginContext(); + const basePath = core.http.basePath.get(); + + useBreadcrumbs([ + { text: UX_LABEL, href: basePath + '/app/ux' }, + { + text: i18n.translate('xpack.apm.ux.overview', { + defaultMessage: 'Overview', + }), + }, + ]); return ( - +
- +
); } -export function CsmAppRoot({ +export function UXAppRoot({ appMountParameters, core, deps, config, - corePlugins: { embeddable, maps }, + corePlugins: { embeddable, maps, observability }, observabilityRuleTypeRegistry, }: { appMountParameters: AppMountParameters; @@ -91,6 +96,7 @@ export function CsmAppRoot({ config, core, plugins, + observability, observabilityRuleTypeRegistry, }; @@ -101,7 +107,7 @@ export function CsmAppRoot({ - + @@ -142,7 +148,7 @@ export const renderApp = ({ }); ReactDOM.render( - ({ totalPageViews: 0 }); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx index d04bcb79a53e1d..4b31ee63eb7ad2 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx @@ -6,14 +6,10 @@ */ import React from 'react'; -import { EuiFlexItem } from '@elastic/eui'; -import { EnvironmentFilter } from '../../../shared/EnvironmentFilter'; import { ServiceNameFilter } from '../URLFilter/ServiceNameFilter'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { RUM_AGENT_NAMES } from '../../../../../common/agent_name'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { UserPercentile } from '../UserPercentile'; -import { useBreakPoints } from '../../../../hooks/use_break_points'; export function MainFilters() { const { @@ -39,25 +35,11 @@ export function MainFilters() { ); const rumServiceNames = data?.rumServices ?? []; - const { isSmall } = useBreakPoints(); - - // on mobile we want it to take full width - const envStyle = isSmall ? {} : { maxWidth: 200 }; return ( - <> - - - - - - - - - - + ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx index a0b3781a30b209..40f091ad1a9fc1 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx @@ -5,33 +5,58 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { RumOverview } from '../RumDashboard'; import { CsmSharedContextProvider } from './CsmSharedContext'; import { MainFilters } from './Panels/MainFilters'; import { DatePicker } from '../../shared/DatePicker'; +import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; +import { EnvironmentFilter } from '../../shared/EnvironmentFilter'; +import { UserPercentile } from './UserPercentile'; +import { useBreakPoints } from '../../../hooks/use_break_points'; export const UX_LABEL = i18n.translate('xpack.apm.ux.title', { defaultMessage: 'User Experience', }); export function RumHome() { + const { observability } = useApmPluginContext(); + const PageTemplateComponent = observability.navigation.PageTemplate; + + const { isSmall } = useBreakPoints(); + + const envStyle = isSmall ? {} : { maxWidth: 200 }; + return ( - - - -

{UX_LABEL}

-
-
- - - - -
- + , +
+ +
, + , + , + ], + }} + > + +
); } + +export function UxHomeHeaderItems() { + return ( + + + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx index 9bdad14eb8a188..e42cb5b2989b62 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/index.tsx @@ -25,19 +25,16 @@ export function RumOverview() { }, []); return ( - <> - - - - - - - - - - - - - + + + + + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx index ec42a117832734..b332c491f6e55f 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/apm_plugin_context.tsx @@ -11,6 +11,7 @@ import type { ObservabilityRuleTypeRegistry } from '../../../../observability/pu import { ConfigSchema } from '../..'; import { ApmPluginSetupDeps } from '../../plugin'; import { MapsStartApi } from '../../../../maps/public'; +import { ObservabilityPublicStart } from '../../../../observability/public'; export interface ApmPluginContextValue { appMountParameters: AppMountParameters; @@ -18,6 +19,7 @@ export interface ApmPluginContextValue { core: CoreStart; plugins: ApmPluginSetupDeps & { maps?: MapsStartApi }; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; + observability: ObservabilityPublicStart; } export const ApmPluginContext = createContext({} as ApmPluginContextValue); diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 845b18b707f930..24db9e0cd8504f 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -67,8 +67,8 @@ export interface ApmPluginStartDeps { licensing: void; maps?: MapsStartApi; ml?: MlPluginStart; - observability: ObservabilityPublicStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + observability: ObservabilityPublicStart; } export class ApmPlugin implements Plugin { @@ -150,6 +150,26 @@ export class ApmPlugin implements Plugin { }, }); + plugins.observability.navigation.registerSections( + of([ + { + label: 'User Experience', + sortKey: 201, + entries: [ + { + label: i18n.translate('xpack.apm.ux.overview.heading', { + defaultMessage: 'Overview', + }), + app: 'ux', + path: '/', + matchFullPath: true, + ignoreTrailingSlash: true, + }, + ], + }, + ]) + ); + core.application.register({ id: 'apm', title: 'APM', @@ -231,7 +251,7 @@ export class ApmPlugin implements Plugin { async mount(appMountParameters: AppMountParameters) { // Load application bundle and Get start service const [{ renderApp }, [coreStart, corePlugins]] = await Promise.all([ - import('./application/csmApp'), + import('./application/uxApp'), core.getStartServices(), ]); diff --git a/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx b/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx index 14c4e0a7cb9afd..d16fbf6f7cd140 100644 --- a/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx +++ b/x-pack/plugins/observability/public/hooks/use_kibana_ui_settings.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import { usePluginContext } from './use_plugin_context'; import { UI_SETTINGS } from '../../../../../src/plugins/data/public'; +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; export { UI_SETTINGS }; @@ -14,6 +14,8 @@ type SettingKeys = keyof typeof UI_SETTINGS; type SettingValues = typeof UI_SETTINGS[SettingKeys]; export function useKibanaUISettings(key: SettingValues): T { - const { core } = usePluginContext(); - return core.uiSettings.get(key); + const { + services: { uiSettings }, + } = useKibana(); + return uiSettings!.get(key); } diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index a49d3461529c28..030046ce7bed90 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -57,6 +57,7 @@ export { useFetcher, FETCH_STATUS } from './hooks/use_fetcher'; export * from './typings'; export { useChartTheme } from './hooks/use_chart_theme'; +export { useBreadcrumbs } from './hooks/use_breadcrumbs'; export { useTheme } from './hooks/use_theme'; export { getApmTraceUrl } from './utils/get_apm_trace_url'; export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils'; diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 4d99e877291b5e..60717db8af27d8 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -7,8 +7,7 @@ import React, { useEffect } from 'react'; import { Provider as ReduxProvider } from 'react-redux'; import { Router } from 'react-router-dom'; -import styled from 'styled-components'; -import { EuiPage, EuiErrorBoundary } from '@elastic/eui'; +import { EuiErrorBoundary } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { I18nStart, ChromeBreadcrumb, CoreStart, AppMountParameters } from 'kibana/public'; import { @@ -62,18 +61,6 @@ export interface UptimeAppProps { appMountParameters: AppMountParameters; } -const StyledPage = styled(EuiPage)` - display: flex; - flex-grow: 1; - flex-shrink: 0; - flex-basis: auto; - flex-direction: column; - - > * { - flex-shrink: 0; - } -`; - const Application = (props: UptimeAppProps) => { const { basePath, @@ -131,7 +118,7 @@ const Application = (props: UptimeAppProps) => { - +
@@ -139,7 +126,7 @@ const Application = (props: UptimeAppProps) => {
- +
From 7b4b7132375fed65a5ef3f0fccfa361371bf2f20 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Fri, 4 Jun 2021 09:22:40 -0700 Subject: [PATCH 73/77] Update CODEOWNERS to ping Stack Management team. (#101350) --- .github/CODEOWNERS | 48 +++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 725708e8a8af2c..0cf5fc4e0dfd0f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -128,7 +128,7 @@ /x-pack/test/functional_basic/apps/ml/ @elastic/ml-ui /x-pack/test/functional_with_es_ssl/apps/ml/ @elastic/ml-ui -# ML team owns and maintains the transform plugin despite it living in the Elasticsearch management section. +# ML team owns and maintains the transform plugin despite it living in the Data management section. /x-pack/plugins/transform/ @elastic/ml-ui /x-pack/test/accessibility/apps/transform.ts @elastic/ml-ui /x-pack/test/api_integration/apis/transform/ @elastic/ml-ui @@ -305,29 +305,29 @@ /x-pack/plugins/enterprise_search/server/collectors/workplace_search/ @elastic/workplace-search-frontend /x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/ @elastic/workplace-search-frontend -# Elasticsearch UI -/src/plugins/dev_tools/ @elastic/es-ui -/src/plugins/console/ @elastic/es-ui -/src/plugins/es_ui_shared/ @elastic/es-ui -/x-pack/plugins/cross_cluster_replication/ @elastic/es-ui -/x-pack/plugins/index_lifecycle_management/ @elastic/es-ui -/x-pack/plugins/console_extensions/ @elastic/es-ui -/x-pack/plugins/grokdebugger/ @elastic/es-ui -/x-pack/plugins/index_management/ @elastic/es-ui -/x-pack/plugins/license_api_guard/ @elastic/es-ui -/x-pack/plugins/license_management/ @elastic/es-ui -/x-pack/plugins/painless_lab/ @elastic/es-ui -/x-pack/plugins/remote_clusters/ @elastic/es-ui -/x-pack/plugins/rollup/ @elastic/es-ui -/x-pack/plugins/searchprofiler/ @elastic/es-ui -/x-pack/plugins/snapshot_restore/ @elastic/es-ui -/x-pack/plugins/upgrade_assistant/ @elastic/es-ui -/x-pack/plugins/watcher/ @elastic/es-ui -/x-pack/plugins/ingest_pipelines/ @elastic/es-ui -/packages/kbn-ace/ @elastic/es-ui -/packages/kbn-monaco/ @elastic/es-ui -#CC# /x-pack/plugins/console_extensions/ @elastic/es-ui -#CC# /x-pack/plugins/cross_cluster_replication/ @elastic/es-ui +# Stack Management +/src/plugins/dev_tools/ @elastic/kibana-stack-management +/src/plugins/console/ @elastic/kibana-stack-management +/src/plugins/es_ui_shared/ @elastic/kibana-stack-management +/x-pack/plugins/cross_cluster_replication/ @elastic/kibana-stack-management +/x-pack/plugins/index_lifecycle_management/ @elastic/kibana-stack-management +/x-pack/plugins/console_extensions/ @elastic/kibana-stack-management +/x-pack/plugins/grokdebugger/ @elastic/kibana-stack-management +/x-pack/plugins/index_management/ @elastic/kibana-stack-management +/x-pack/plugins/license_api_guard/ @elastic/kibana-stack-management +/x-pack/plugins/license_management/ @elastic/kibana-stack-management +/x-pack/plugins/painless_lab/ @elastic/kibana-stack-management +/x-pack/plugins/remote_clusters/ @elastic/kibana-stack-management +/x-pack/plugins/rollup/ @elastic/kibana-stack-management +/x-pack/plugins/searchprofiler/ @elastic/kibana-stack-management +/x-pack/plugins/snapshot_restore/ @elastic/kibana-stack-management +/x-pack/plugins/upgrade_assistant/ @elastic/kibana-stack-management +/x-pack/plugins/watcher/ @elastic/kibana-stack-management +/x-pack/plugins/ingest_pipelines/ @elastic/kibana-stack-management +/packages/kbn-ace/ @elastic/kibana-stack-management +/packages/kbn-monaco/ @elastic/kibana-stack-management +#CC# /x-pack/plugins/console_extensions/ @elastic/kibana-stack-management +#CC# /x-pack/plugins/cross_cluster_replication/ @elastic/kibana-stack-management # Security Solution /x-pack/test/endpoint_api_integration_no_ingest/ @elastic/security-solution From 081cf98f618bbca9e729bffb02bd305feea6af13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Fri, 4 Jun 2021 19:13:35 +0200 Subject: [PATCH 74/77] [Logs UI] Fix the LogStream story to work with KIPs (#100862) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../log_stream/log_stream.stories.mdx | 50 ++++++++++++++++- .../hooks/use_kibana_index_patterns.mock.tsx | 55 ++++++++++++------- 2 files changed, 84 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx b/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx index 7f1636b00d24e3..87419a9bfbe782 100644 --- a/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx @@ -3,10 +3,11 @@ import { defer, of, Subject } from 'rxjs'; import { delay } from 'rxjs/operators'; import { I18nProvider } from '@kbn/i18n/react'; +import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; import { EuiThemeProvider } from '../../../../../../src/plugins/kibana_react/common'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; - import { LOG_ENTRIES_SEARCH_STRATEGY } from '../../../common/search_strategies/log_entries/log_entries'; +import { createIndexPatternMock, createIndexPatternsMock } from '../../hooks/use_kibana_index_patterns.mock'; import { DEFAULT_SOURCE_CONFIGURATION } from '../../test_utils/source_configuration'; import { generateFakeEntries, ENTRIES_EMPTY } from '../../test_utils/entries'; @@ -18,6 +19,45 @@ export const startTimestamp = 1595145600000; export const endTimestamp = startTimestamp + 15 * 60 * 1000; export const dataMock = { + indexPatterns: createIndexPatternsMock(500, [ + createIndexPatternMock({ + id: 'some-test-id', + title: 'mock-index-pattern-*', + timeFieldName: '@timestamp', + fields: [ + { + name: '@timestamp', + type: KBN_FIELD_TYPES.DATE, + searchable: true, + aggregatable: true, + }, + { + name: 'event.dataset', + type: KBN_FIELD_TYPES.STRING, + searchable: true, + aggregatable: true, + }, + { + name: 'host.name', + type: KBN_FIELD_TYPES.STRING, + searchable: true, + aggregatable: true, + }, + { + name: 'log.level', + type: KBN_FIELD_TYPES.STRING, + searchable: true, + aggregatable: true, + }, + { + name: 'message', + type: KBN_FIELD_TYPES.STRING, + searchable: true, + aggregatable: true, + }, + ], + }) + ]), search: { search: ({ params }, options) => { return defer(() => { @@ -68,10 +108,16 @@ export const dataMock = { }; -export const fetch = function (url, params) { +export const fetch = async function (url, params) { switch (url) { case '/api/infra/log_source_configurations/default': return DEFAULT_SOURCE_CONFIGURATION; + case '/api/infra/log_source_configurations/default/status': + return { + data: { + logIndexStatus: 'available', + } + }; default: return {}; } diff --git a/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx index dbf032415cb992..9d3a611cff88d0 100644 --- a/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx +++ b/x-pack/plugins/infra/public/hooks/use_kibana_index_patterns.mock.tsx @@ -21,7 +21,7 @@ import { Pick2 } from '../../common/utility_types'; type MockIndexPattern = Pick< IndexPattern, - 'id' | 'title' | 'type' | 'getTimeField' | 'isTimeBased' | 'getFieldByName' + 'id' | 'title' | 'type' | 'getTimeField' | 'isTimeBased' | 'getFieldByName' | 'getComputedFields' >; export type MockIndexPatternSpec = Pick< IIndexPattern, @@ -35,23 +35,7 @@ export const MockIndexPatternsKibanaContextProvider: React.FC<{ mockIndexPatterns: MockIndexPatternSpec[]; }> = ({ asyncDelay, children, mockIndexPatterns }) => { const indexPatterns = useMemo( - () => - createIndexPatternsMock( - asyncDelay, - mockIndexPatterns.map(({ id, title, type = undefined, fields, timeFieldName }) => { - const indexPatternFields = fields.map((fieldSpec) => new IndexPatternField(fieldSpec)); - - return { - id, - title, - type, - getTimeField: () => indexPatternFields.find(({ name }) => name === timeFieldName), - isTimeBased: () => timeFieldName != null, - getFieldByName: (fieldName) => - indexPatternFields.find(({ name }) => name === fieldName), - }; - }) - ), + () => createIndexPatternsMock(asyncDelay, mockIndexPatterns.map(createIndexPatternMock)), [asyncDelay, mockIndexPatterns] ); @@ -71,7 +55,7 @@ export const MockIndexPatternsKibanaContextProvider: React.FC<{ ); }; -const createIndexPatternsMock = ( +export const createIndexPatternsMock = ( asyncDelay: number, indexPatterns: MockIndexPattern[] ): { @@ -93,3 +77,36 @@ const createIndexPatternsMock = ( }, }; }; + +export const createIndexPatternMock = ({ + id, + title, + type = undefined, + fields, + timeFieldName, +}: MockIndexPatternSpec): MockIndexPattern => { + const indexPatternFields = fields.map((fieldSpec) => new IndexPatternField(fieldSpec)); + + return { + id, + title, + type, + getTimeField: () => indexPatternFields.find(({ name }) => name === timeFieldName), + isTimeBased: () => timeFieldName != null, + getFieldByName: (fieldName) => indexPatternFields.find(({ name }) => name === fieldName), + getComputedFields: () => ({ + docvalueFields: [], + runtimeFields: indexPatternFields.reduce((accumulatedRuntimeFields, field) => { + if (field.runtimeField != null) { + return { + ...accumulatedRuntimeFields, + [field.name]: field.runtimeField, + }; + } + return accumulatedRuntimeFields; + }, {}), + scriptFields: {}, + storedFields: [], + }), + }; +}; From 090d0abd11c126b4f4fe1099bd62311b6554ee9e Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 4 Jun 2021 10:17:00 -0700 Subject: [PATCH 75/77] [ts] migrate root test dir to project refs (#99148) Co-authored-by: spalger --- .../functional_test_runner/public_types.ts | 6 + ...r_context.d.ts => ftr_provider_context.ts} | 3 +- test/accessibility/services/a11y/a11y.ts | 136 +- test/accessibility/services/a11y/index.ts | 2 +- test/accessibility/services/index.ts | 4 +- test/functional/page_objects/common_page.ts | 809 ++++++----- test/functional/page_objects/console_page.ts | 150 +- test/functional/page_objects/context_page.ts | 144 +- .../functional/page_objects/dashboard_page.ts | 1091 ++++++++------- test/functional/page_objects/discover_page.ts | 829 ++++++----- test/functional/page_objects/error_page.ts | 36 +- test/functional/page_objects/header_page.ts | 144 +- test/functional/page_objects/home_page.ts | 214 ++- test/functional/page_objects/index.ts | 96 +- .../page_objects/legacy/data_table_vis.ts | 133 +- test/functional/page_objects/login_page.ts | 98 +- .../management/saved_objects_page.ts | 559 ++++---- test/functional/page_objects/newsfeed_page.ts | 86 +- test/functional/page_objects/settings_page.ts | 1235 ++++++++--------- test/functional/page_objects/share_page.ts | 114 +- .../functional/page_objects/tag_cloud_page.ts | 47 +- test/functional/page_objects/tile_map_page.ts | 146 +- test/functional/page_objects/time_picker.ts | 457 +++--- .../page_objects/time_to_visualize_page.ts | 183 ++- test/functional/page_objects/timelion_page.ts | 118 +- .../page_objects/vega_chart_page.ts | 153 +- .../page_objects/visual_builder_page.ts | 1101 ++++++++------- .../page_objects/visualize_chart_page.ts | 1040 +++++++------- .../page_objects/visualize_editor_page.ts | 870 ++++++------ .../functional/page_objects/visualize_page.ts | 740 +++++----- test/functional/services/combo_box.ts | 4 +- .../services/dashboard/add_panel.ts | 15 +- .../services/dashboard/expectations.ts | 11 +- .../services/dashboard/panel_actions.ts | 10 +- .../services/dashboard/visualizations.ts | 60 +- test/functional/services/data_grid.ts | 8 +- test/functional/services/doc_table.ts | 10 +- test/functional/services/embedding.ts | 4 +- test/functional/services/filter_bar.ts | 17 +- test/functional/services/index.ts | 4 +- test/functional/services/listing_table.ts | 4 +- test/functional/services/monaco_editor.ts | 34 +- test/functional/services/query_bar.ts | 11 +- .../services/remote/prevent_parallel_calls.ts | 59 +- .../saved_query_management_component.ts | 4 +- .../services/visualizations/pie_chart.ts | 36 +- .../plugins/core_app_status/tsconfig.json | 10 +- .../core_provider_plugin/tsconfig.json | 9 +- test/tsconfig.json | 13 +- ...r_context.d.ts => ftr_provider_context.ts} | 3 +- test/visual_regression/services/index.ts | 4 +- .../services/visual_testing/index.ts | 2 +- .../services/visual_testing/visual_testing.ts | 136 +- tsconfig.refs.json | 1 + 54 files changed, 5581 insertions(+), 5632 deletions(-) rename test/accessibility/{ftr_provider_context.d.ts => ftr_provider_context.ts} (78%) rename test/visual_regression/{ftr_provider_context.d.ts => ftr_provider_context.ts} (78%) diff --git a/packages/kbn-test/src/functional_test_runner/public_types.ts b/packages/kbn-test/src/functional_test_runner/public_types.ts index 4a30744c09b516..d94f61e23b8b80 100644 --- a/packages/kbn-test/src/functional_test_runner/public_types.ts +++ b/packages/kbn-test/src/functional_test_runner/public_types.ts @@ -74,6 +74,12 @@ export interface GenericFtrProviderContext< getService(serviceName: 'failureMetadata'): FailureMetadata; getService(serviceName: T): ServiceMap[T]; + /** + * Get the instance of a page object + * @param pageObjectName + */ + getPageObject(pageObjectName: K): PageObjectMap[K]; + /** * Get a map of PageObjects * @param pageObjects diff --git a/test/accessibility/ftr_provider_context.d.ts b/test/accessibility/ftr_provider_context.ts similarity index 78% rename from test/accessibility/ftr_provider_context.d.ts rename to test/accessibility/ftr_provider_context.ts index 4c827393e1ef3b..a1a29f50b77611 100644 --- a/test/accessibility/ftr_provider_context.d.ts +++ b/test/accessibility/ftr_provider_context.ts @@ -6,9 +6,10 @@ * Side Public License, v 1. */ -import { GenericFtrProviderContext } from '@kbn/test'; +import { GenericFtrProviderContext, GenericFtrService } from '@kbn/test'; import { pageObjects } from './page_objects'; import { services } from './services'; export type FtrProviderContext = GenericFtrProviderContext; +export class FtrService extends GenericFtrService {} diff --git a/test/accessibility/services/a11y/a11y.ts b/test/accessibility/services/a11y/a11y.ts index ea205e8121eba0..4b01b0dd3b9531 100644 --- a/test/accessibility/services/a11y/a11y.ts +++ b/test/accessibility/services/a11y/a11y.ts @@ -9,7 +9,7 @@ import chalk from 'chalk'; import testSubjectToCss from '@kbn/test-subj-selector'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrService } from '../../ftr_provider_context'; import { AxeReport, printResult } from './axe_report'; // @ts-ignore JS that is run in browser as is import { analyzeWithAxe, analyzeWithAxeWithClient } from './analyze_with_axe'; @@ -33,86 +33,84 @@ export const normalizeResult = (report: any) => { return report.result as false | AxeReport; }; -export function A11yProvider({ getService }: FtrProviderContext) { - const browser = getService('browser'); - const Wd = getService('__webdriver__'); - - /** - * Accessibility testing service using the Axe (https://www.deque.com/axe/) - * toolset to validate a11y rules similar to ESLint. In order to test against - * the rules we must load up the UI and feed a full HTML snapshot into Axe. - */ - return new (class Accessibility { - public async testAppSnapshot(options: TestOptions = {}) { - const context = this.getAxeContext(true, options.excludeTestSubj); - const report = await this.captureAxeReport(context); - await this.testAxeReport(report); - } +/** + * Accessibility testing service using the Axe (https://www.deque.com/axe/) + * toolset to validate a11y rules similar to ESLint. In order to test against + * the rules we must load up the UI and feed a full HTML snapshot into Axe. + */ +export class AccessibilityService extends FtrService { + private readonly browser = this.ctx.getService('browser'); + private readonly Wd = this.ctx.getService('__webdriver__'); + + public async testAppSnapshot(options: TestOptions = {}) { + const context = this.getAxeContext(true, options.excludeTestSubj); + const report = await this.captureAxeReport(context); + this.assertValidAxeReport(report); + } - public async testGlobalSnapshot(options: TestOptions = {}) { - const context = this.getAxeContext(false, options.excludeTestSubj); - const report = await this.captureAxeReport(context); - await this.testAxeReport(report); - } + public async testGlobalSnapshot(options: TestOptions = {}) { + const context = this.getAxeContext(false, options.excludeTestSubj); + const report = await this.captureAxeReport(context); + this.assertValidAxeReport(report); + } - private getAxeContext(global: boolean, excludeTestSubj?: string | string[]): AxeContext { - return { - include: global ? undefined : [testSubjectToCss('appA11yRoot')], - exclude: ([] as string[]) - .concat(excludeTestSubj || []) - .map((ts) => [testSubjectToCss(ts)]) - .concat([['[role="graphics-document"][aria-roledescription="visualization"]']]), - }; - } + private getAxeContext(global: boolean, excludeTestSubj?: string | string[]): AxeContext { + return { + include: global ? undefined : [testSubjectToCss('appA11yRoot')], + exclude: ([] as string[]) + .concat(excludeTestSubj || []) + .map((ts) => [testSubjectToCss(ts)]) + .concat([['[role="graphics-document"][aria-roledescription="visualization"]']]), + }; + } - private testAxeReport(report: AxeReport) { - const errorMsgs = []; + private assertValidAxeReport(report: AxeReport) { + const errorMsgs = []; - for (const result of report.violations) { - errorMsgs.push(printResult(chalk.red('VIOLATION'), result)); - } + for (const result of report.violations) { + errorMsgs.push(printResult(chalk.red('VIOLATION'), result)); + } - if (errorMsgs.length) { - throw new Error(`a11y report:\n${errorMsgs.join('\n')}`); - } + if (errorMsgs.length) { + throw new Error(`a11y report:\n${errorMsgs.join('\n')}`); } + } - private async captureAxeReport(context: AxeContext): Promise { - const axeOptions = { - reporter: 'v2', - runOnly: ['wcag2a', 'wcag2aa'], - rules: { - 'color-contrast': { - enabled: false, // disabled because we have too many failures - }, - bypass: { - enabled: false, // disabled because it's too flaky - }, + private async captureAxeReport(context: AxeContext): Promise { + const axeOptions = { + reporter: 'v2', + runOnly: ['wcag2a', 'wcag2aa'], + rules: { + 'color-contrast': { + enabled: false, // disabled because we have too many failures }, - }; - - await (Wd.driver.manage() as any).setTimeouts({ - ...(await (Wd.driver.manage() as any).getTimeouts()), - script: 600000, - }); + bypass: { + enabled: false, // disabled because it's too flaky + }, + }, + }; - const report = normalizeResult( - await browser.executeAsync(analyzeWithAxe, context, axeOptions) - ); + await this.Wd.driver.manage().setTimeouts({ + ...(await this.Wd.driver.manage().getTimeouts()), + script: 600000, + }); - if (report !== false) { - return report; - } + const report = normalizeResult( + await this.browser.executeAsync(analyzeWithAxe, context, axeOptions) + ); - const withClientReport = normalizeResult( - await browser.executeAsync(analyzeWithAxeWithClient, context, axeOptions) - ); + if (report !== false) { + return report; + } - if (withClientReport === false) { - throw new Error('Attempted to analyze with axe but failed to load axe client'); - } + const withClientReport = normalizeResult( + await this.browser.executeAsync(analyzeWithAxeWithClient, context, axeOptions) + ); - return withClientReport; + if (withClientReport === false) { + throw new Error('Attempted to analyze with axe but failed to load axe client'); } - })(); + + return withClientReport; + } } diff --git a/test/accessibility/services/a11y/index.ts b/test/accessibility/services/a11y/index.ts index 79912dd99d326f..642b170c4e0771 100644 --- a/test/accessibility/services/a11y/index.ts +++ b/test/accessibility/services/a11y/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { A11yProvider } from './a11y'; +export * from './a11y'; diff --git a/test/accessibility/services/index.ts b/test/accessibility/services/index.ts index ec3bf534590b34..ef5674d4011fbb 100644 --- a/test/accessibility/services/index.ts +++ b/test/accessibility/services/index.ts @@ -7,9 +7,9 @@ */ import { services as kibanaFunctionalServices } from '../../functional/services'; -import { A11yProvider } from './a11y'; +import { AccessibilityService } from './a11y'; export const services = { ...kibanaFunctionalServices, - a11y: A11yProvider, + a11y: AccessibilityService, }; diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index bc60b8ce5f19c2..49d56d6f437847 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -11,470 +11,465 @@ import expect from '@kbn/expect'; // @ts-ignore import fetch from 'node-fetch'; import { getUrl } from '@kbn/test'; -import { FtrProviderContext } from '../ftr_provider_context'; - -export function CommonPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const config = getService('config'); - const browser = getService('browser'); - const retry = getService('retry'); - const find = getService('find'); - const globalNav = getService('globalNav'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['login']); - - const defaultTryTimeout = config.get('timeouts.try'); - const defaultFindTimeout = config.get('timeouts.find'); - - interface NavigateProps { - appConfig: {}; - ensureCurrentUrl: boolean; - shouldLoginIfPrompted: boolean; - useActualUrl: boolean; - insertTimestamp: boolean; - } - - class CommonPage { - /** - * Logins to Kibana as default user and navigates to provided app - * @param appUrl Kibana URL - */ - private async loginIfPrompted(appUrl: string, insertTimestamp: boolean) { - // Disable the welcome screen. This is relevant for environments - // which don't allow to use the yml setting, e.g. cloud production. - // It is done here so it applies to logins but also to a login re-use. - await browser.setLocalStorageItem('home:welcome:show', 'false'); - - let currentUrl = await browser.getCurrentUrl(); - log.debug(`currentUrl = ${currentUrl}\n appUrl = ${appUrl}`); - await testSubjects.find('kibanaChrome', 6 * defaultFindTimeout); // 60 sec waiting - const loginPage = currentUrl.includes('/login'); - const wantedLoginPage = appUrl.includes('/login') || appUrl.includes('/logout'); - - if (loginPage && !wantedLoginPage) { - log.debug('Found login page'); - if (config.get('security.disableTestUser')) { - await PageObjects.login.login( - config.get('servers.kibana.username'), - config.get('servers.kibana.password') - ); - } else { - await PageObjects.login.login('test_user', 'changeme'); - } - - await find.byCssSelector( - '[data-test-subj="kibanaChrome"] nav:not(.ng-hide)', - 6 * defaultFindTimeout +import { FtrService } from '../ftr_provider_context'; + +interface NavigateProps { + appConfig: {}; + ensureCurrentUrl: boolean; + shouldLoginIfPrompted: boolean; + useActualUrl: boolean; + insertTimestamp: boolean; +} +export class CommonPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly config = this.ctx.getService('config'); + private readonly browser = this.ctx.getService('browser'); + private readonly retry = this.ctx.getService('retry'); + private readonly find = this.ctx.getService('find'); + private readonly globalNav = this.ctx.getService('globalNav'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly loginPage = this.ctx.getPageObject('login'); + + private readonly defaultTryTimeout = this.config.get('timeouts.try'); + private readonly defaultFindTimeout = this.config.get('timeouts.find'); + + /** + * Logins to Kibana as default user and navigates to provided app + * @param appUrl Kibana URL + */ + private async loginIfPrompted(appUrl: string, insertTimestamp: boolean) { + // Disable the welcome screen. This is relevant for environments + // which don't allow to use the yml setting, e.g. cloud production. + // It is done here so it applies to logins but also to a login re-use. + await this.browser.setLocalStorageItem('home:welcome:show', 'false'); + + let currentUrl = await this.browser.getCurrentUrl(); + this.log.debug(`currentUrl = ${currentUrl}\n appUrl = ${appUrl}`); + await this.testSubjects.find('kibanaChrome', 6 * this.defaultFindTimeout); // 60 sec waiting + const loginPage = currentUrl.includes('/login'); + const wantedLoginPage = appUrl.includes('/login') || appUrl.includes('/logout'); + + if (loginPage && !wantedLoginPage) { + this.log.debug('Found login page'); + if (this.config.get('security.disableTestUser')) { + await this.loginPage.login( + this.config.get('servers.kibana.username'), + this.config.get('servers.kibana.password') ); - await browser.get(appUrl, insertTimestamp); - currentUrl = await browser.getCurrentUrl(); - log.debug(`Finished login process currentUrl = ${currentUrl}`); + } else { + await this.loginPage.login('test_user', 'changeme'); } - return currentUrl; - } - private async navigate(navigateProps: NavigateProps) { - const { - appConfig, - ensureCurrentUrl, - shouldLoginIfPrompted, - useActualUrl, - insertTimestamp, - } = navigateProps; - const appUrl = getUrl.noAuth(config.get('servers.kibana'), appConfig); - - await retry.try(async () => { - if (useActualUrl) { - log.debug(`navigateToActualUrl ${appUrl}`); - await browser.get(appUrl); - } else { - log.debug(`navigateToUrl ${appUrl}`); - await browser.get(appUrl, insertTimestamp); - } + await this.find.byCssSelector( + '[data-test-subj="kibanaChrome"] nav:not(.ng-hide)', + 6 * this.defaultFindTimeout + ); + await this.browser.get(appUrl, insertTimestamp); + currentUrl = await this.browser.getCurrentUrl(); + this.log.debug(`Finished login process currentUrl = ${currentUrl}`); + } + return currentUrl; + } - // accept alert if it pops up - const alert = await browser.getAlert(); - await alert?.accept(); + private async navigate(navigateProps: NavigateProps) { + const { + appConfig, + ensureCurrentUrl, + shouldLoginIfPrompted, + useActualUrl, + insertTimestamp, + } = navigateProps; + const appUrl = getUrl.noAuth(this.config.get('servers.kibana'), appConfig); + + await this.retry.try(async () => { + if (useActualUrl) { + this.log.debug(`navigateToActualUrl ${appUrl}`); + await this.browser.get(appUrl); + } else { + this.log.debug(`navigateToUrl ${appUrl}`); + await this.browser.get(appUrl, insertTimestamp); + } - const currentUrl = shouldLoginIfPrompted - ? await this.loginIfPrompted(appUrl, insertTimestamp) - : await browser.getCurrentUrl(); + // accept alert if it pops up + const alert = await this.browser.getAlert(); + await alert?.accept(); - if (ensureCurrentUrl && !currentUrl.includes(appUrl)) { - throw new Error(`expected ${currentUrl}.includes(${appUrl})`); - } - }); - } + const currentUrl = shouldLoginIfPrompted + ? await this.loginIfPrompted(appUrl, insertTimestamp) + : await this.browser.getCurrentUrl(); - /** - * Navigates browser using the pathname from the appConfig and subUrl as the hash - * @param appName As defined in the apps config, e.g. 'home' - * @param subUrl The route after the hash (#), e.g. '/tutorial_directory/sampleData' - * @param args additional arguments - */ - public async navigateToUrl( - appName: string, - subUrl?: string, - { - basePath = '', - ensureCurrentUrl = true, - shouldLoginIfPrompted = true, - useActualUrl = false, - insertTimestamp = true, - shouldUseHashForSubUrl = true, - } = {} - ) { - const appConfig: { pathname: string; hash?: string } = { - pathname: `${basePath}${config.get(['apps', appName]).pathname}`, - }; - - if (shouldUseHashForSubUrl) { - appConfig.hash = useActualUrl ? subUrl : `/${appName}/${subUrl}`; - } else { - appConfig.pathname += `/${subUrl}`; + if (ensureCurrentUrl && !currentUrl.includes(appUrl)) { + throw new Error(`expected ${currentUrl}.includes(${appUrl})`); } + }); + } - await this.navigate({ - appConfig, - ensureCurrentUrl, - shouldLoginIfPrompted, - useActualUrl, - insertTimestamp, - }); + /** + * Navigates browser using the pathname from the appConfig and subUrl as the hash + * @param appName As defined in the apps config, e.g. 'home' + * @param subUrl The route after the hash (#), e.g. '/tutorial_directory/sampleData' + * @param args additional arguments + */ + public async navigateToUrl( + appName: string, + subUrl?: string, + { + basePath = '', + ensureCurrentUrl = true, + shouldLoginIfPrompted = true, + useActualUrl = false, + insertTimestamp = true, + shouldUseHashForSubUrl = true, + } = {} + ) { + const appConfig: { pathname: string; hash?: string } = { + pathname: `${basePath}${this.config.get(['apps', appName]).pathname}`, + }; + + if (shouldUseHashForSubUrl) { + appConfig.hash = useActualUrl ? subUrl : `/${appName}/${subUrl}`; + } else { + appConfig.pathname += `/${subUrl}`; } - /** - * Navigates browser using the pathname from the appConfig and subUrl as the extended path. - * This was added to be able to test an application that uses browser history over hash history. - * @param appName As defined in the apps config, e.g. 'home' - * @param subUrl The route after the appUrl, e.g. '/tutorial_directory/sampleData' - * @param args additional arguments - */ - public async navigateToUrlWithBrowserHistory( - appName: string, - subUrl?: string, - search?: string, - { - basePath = '', - ensureCurrentUrl = true, - shouldLoginIfPrompted = true, - useActualUrl = true, - insertTimestamp = true, - } = {} - ) { - const appConfig = { - // subUrl following the basePath, assumes no hashes. Ex: 'app/endpoint/management' - pathname: `${basePath}${config.get(['apps', appName]).pathname}${subUrl}`, - search, - }; - - await this.navigate({ - appConfig, - ensureCurrentUrl, - shouldLoginIfPrompted, - useActualUrl, - insertTimestamp, - }); - } + await this.navigate({ + appConfig, + ensureCurrentUrl, + shouldLoginIfPrompted, + useActualUrl, + insertTimestamp, + }); + } - /** - * Navigates browser using only the pathname from the appConfig - * @param appName As defined in the apps config, e.g. 'kibana' - * @param hash The route after the hash (#), e.g. 'management/kibana/settings' - * @param args additional arguments - */ - async navigateToActualUrl( - appName: string, - hash?: string, - { basePath = '', ensureCurrentUrl = true, shouldLoginIfPrompted = true } = {} - ) { - await this.navigateToUrl(appName, hash, { - basePath, - ensureCurrentUrl, - shouldLoginIfPrompted, - useActualUrl: true, - }); - } + /** + * Navigates browser using the pathname from the appConfig and subUrl as the extended path. + * This was added to be able to test an application that uses browser history over hash history. + * @param appName As defined in the apps config, e.g. 'home' + * @param subUrl The route after the appUrl, e.g. '/tutorial_directory/sampleData' + * @param args additional arguments + */ + public async navigateToUrlWithBrowserHistory( + appName: string, + subUrl?: string, + search?: string, + { + basePath = '', + ensureCurrentUrl = true, + shouldLoginIfPrompted = true, + useActualUrl = true, + insertTimestamp = true, + } = {} + ) { + const appConfig = { + // subUrl following the basePath, assumes no hashes. Ex: 'app/endpoint/management' + pathname: `${basePath}${this.config.get(['apps', appName]).pathname}${subUrl}`, + search, + }; + + await this.navigate({ + appConfig, + ensureCurrentUrl, + shouldLoginIfPrompted, + useActualUrl, + insertTimestamp, + }); + } - async sleep(sleepMilliseconds: number) { - log.debug(`... sleep(${sleepMilliseconds}) start`); - await delay(sleepMilliseconds); - log.debug(`... sleep(${sleepMilliseconds}) end`); - } + /** + * Navigates browser using only the pathname from the appConfig + * @param appName As defined in the apps config, e.g. 'kibana' + * @param hash The route after the hash (#), e.g. 'management/kibana/settings' + * @param args additional arguments + */ + async navigateToActualUrl( + appName: string, + hash?: string, + { basePath = '', ensureCurrentUrl = true, shouldLoginIfPrompted = true } = {} + ) { + await this.navigateToUrl(appName, hash, { + basePath, + ensureCurrentUrl, + shouldLoginIfPrompted, + useActualUrl: true, + }); + } - async navigateToApp( - appName: string, - { basePath = '', shouldLoginIfPrompted = true, hash = '', insertTimestamp = true } = {} - ) { - let appUrl: string; - if (config.has(['apps', appName])) { - // Legacy applications - const appConfig = config.get(['apps', appName]); - appUrl = getUrl.noAuth(config.get('servers.kibana'), { - pathname: `${basePath}${appConfig.pathname}`, - hash: hash || appConfig.hash, - }); - } else { - appUrl = getUrl.noAuth(config.get('servers.kibana'), { - pathname: `${basePath}/app/${appName}`, - hash, - }); - } + async sleep(sleepMilliseconds: number) { + this.log.debug(`... sleep(${sleepMilliseconds}) start`); + await delay(sleepMilliseconds); + this.log.debug(`... sleep(${sleepMilliseconds}) end`); + } - log.debug('navigating to ' + appName + ' url: ' + appUrl); - - await retry.tryForTime(defaultTryTimeout * 2, async () => { - let lastUrl = await retry.try(async () => { - // since we're using hash URLs, always reload first to force re-render - log.debug('navigate to: ' + appUrl); - await browser.get(appUrl, insertTimestamp); - // accept alert if it pops up - const alert = await browser.getAlert(); - await alert?.accept(); - await this.sleep(700); - log.debug('returned from get, calling refresh'); - await browser.refresh(); - let currentUrl = shouldLoginIfPrompted - ? await this.loginIfPrompted(appUrl, insertTimestamp) - : await browser.getCurrentUrl(); - - if (currentUrl.includes('app/kibana')) { - await testSubjects.find('kibanaChrome'); - } - - currentUrl = (await browser.getCurrentUrl()).replace(/\/\/\w+:\w+@/, '//'); - - const navSuccessful = currentUrl - .replace(':80/', '/') - .replace(':443/', '/') - .startsWith(appUrl); - - if (!navSuccessful) { - const msg = `App failed to load: ${appName} in ${defaultFindTimeout}ms appUrl=${appUrl} currentUrl=${currentUrl}`; - log.debug(msg); - throw new Error(msg); - } - return currentUrl; - }); - - await retry.tryForTime(defaultFindTimeout, async () => { - await this.sleep(501); - const currentUrl = await browser.getCurrentUrl(); - log.debug('in navigateTo url = ' + currentUrl); - if (lastUrl !== currentUrl) { - lastUrl = currentUrl; - throw new Error('URL changed, waiting for it to settle'); - } - }); + async navigateToApp( + appName: string, + { basePath = '', shouldLoginIfPrompted = true, hash = '', insertTimestamp = true } = {} + ) { + let appUrl: string; + if (this.config.has(['apps', appName])) { + // Legacy applications + const appConfig = this.config.get(['apps', appName]); + appUrl = getUrl.noAuth(this.config.get('servers.kibana'), { + pathname: `${basePath}${appConfig.pathname}`, + hash: hash || appConfig.hash, + }); + } else { + appUrl = getUrl.noAuth(this.config.get('servers.kibana'), { + pathname: `${basePath}/app/${appName}`, + hash, }); } - async waitUntilUrlIncludes(path: string) { - await retry.try(async () => { - const url = await browser.getCurrentUrl(); - if (!url.includes(path)) { - throw new Error('Url not found'); + this.log.debug('navigating to ' + appName + ' url: ' + appUrl); + + await this.retry.tryForTime(this.defaultTryTimeout * 2, async () => { + let lastUrl = await this.retry.try(async () => { + // since we're using hash URLs, always reload first to force re-render + this.log.debug('navigate to: ' + appUrl); + await this.browser.get(appUrl, insertTimestamp); + // accept alert if it pops up + const alert = await this.browser.getAlert(); + await alert?.accept(); + await this.sleep(700); + this.log.debug('returned from get, calling refresh'); + await this.browser.refresh(); + let currentUrl = shouldLoginIfPrompted + ? await this.loginIfPrompted(appUrl, insertTimestamp) + : await this.browser.getCurrentUrl(); + + if (currentUrl.includes('app/kibana')) { + await this.testSubjects.find('kibanaChrome'); } - }); - } - async getSharedItemTitleAndDescription() { - const cssSelector = '[data-shared-item][data-title][data-description]'; - const element = await find.byCssSelector(cssSelector); + currentUrl = (await this.browser.getCurrentUrl()).replace(/\/\/\w+:\w+@/, '//'); - return { - title: await element.getAttribute('data-title'), - description: await element.getAttribute('data-description'), - }; - } + const navSuccessful = currentUrl + .replace(':80/', '/') + .replace(':443/', '/') + .startsWith(appUrl); - async getSharedItemContainers() { - const cssSelector = '[data-shared-items-container]'; - return find.allByCssSelector(cssSelector); - } + if (!navSuccessful) { + const msg = `App failed to load: ${appName} in ${this.defaultFindTimeout}ms appUrl=${appUrl} currentUrl=${currentUrl}`; + this.log.debug(msg); + throw new Error(msg); + } + return currentUrl; + }); - async ensureModalOverlayHidden() { - return retry.try(async () => { - const shown = await testSubjects.exists('confirmModalTitleText'); - if (shown) { - throw new Error('Modal overlay is showing'); + await this.retry.tryForTime(this.defaultFindTimeout, async () => { + await this.sleep(501); + const currentUrl = await this.browser.getCurrentUrl(); + this.log.debug('in navigateTo url = ' + currentUrl); + if (lastUrl !== currentUrl) { + lastUrl = currentUrl; + throw new Error('URL changed, waiting for it to settle'); } }); - } + }); + } - async clickConfirmOnModal(ensureHidden = true) { - log.debug('Clicking modal confirm'); - // make sure this data-test-subj 'confirmModalTitleText' exists because we're going to wait for it to be gone later - await testSubjects.exists('confirmModalTitleText'); - await testSubjects.click('confirmModalConfirmButton'); - if (ensureHidden) { - await this.ensureModalOverlayHidden(); + async waitUntilUrlIncludes(path: string) { + await this.retry.try(async () => { + const url = await this.browser.getCurrentUrl(); + if (!url.includes(path)) { + throw new Error('Url not found'); } - } + }); + } - async pressEnterKey() { - await browser.pressKeys(browser.keys.ENTER); - } + async getSharedItemTitleAndDescription() { + const cssSelector = '[data-shared-item][data-title][data-description]'; + const element = await this.find.byCssSelector(cssSelector); - async pressTabKey() { - await browser.pressKeys(browser.keys.TAB); - } + return { + title: await element.getAttribute('data-title'), + description: await element.getAttribute('data-description'), + }; + } - // Pause the browser at a certain place for debugging - // Not meant for usage in CI, only for dev-usage - async pause() { - return browser.pause(); - } + async getSharedItemContainers() { + const cssSelector = '[data-shared-items-container]'; + return this.find.allByCssSelector(cssSelector); + } - /** - * Clicks cancel button on modal - * @param overlayWillStay pass in true if your test will show multiple modals in succession - */ - async clickCancelOnModal(overlayWillStay = true) { - log.debug('Clicking modal cancel'); - await testSubjects.click('confirmModalCancelButton'); - if (!overlayWillStay) { - await this.ensureModalOverlayHidden(); + async ensureModalOverlayHidden() { + return this.retry.try(async () => { + const shown = await this.testSubjects.exists('confirmModalTitleText'); + if (shown) { + throw new Error('Modal overlay is showing'); } - } + }); + } - async expectConfirmModalOpenState(state: boolean) { - log.debug(`expectConfirmModalOpenState(${state})`); - // we use retry here instead of a simple .exists() check because the modal - // fades in/out, which takes time, and we really only care that at some point - // the modal is either open or closed - await retry.try(async () => { - const actualState = await testSubjects.exists('confirmModalCancelButton'); - expect(actualState).to.equal( - state, - state ? 'Confirm modal should be present' : 'Confirm modal should be hidden' - ); - }); + async clickConfirmOnModal(ensureHidden = true) { + this.log.debug('Clicking modal confirm'); + // make sure this data-test-subj 'confirmModalTitleText' exists because we're going to wait for it to be gone later + await this.testSubjects.exists('confirmModalTitleText'); + await this.testSubjects.click('confirmModalConfirmButton'); + if (ensureHidden) { + await this.ensureModalOverlayHidden(); } + } - async isChromeVisible() { - const globalNavShown = await globalNav.exists(); - return globalNavShown; - } + async pressEnterKey() { + await this.browser.pressKeys(this.browser.keys.ENTER); + } - async isChromeHidden() { - const globalNavShown = await globalNav.exists(); - return !globalNavShown; - } + async pressTabKey() { + await this.browser.pressKeys(this.browser.keys.TAB); + } - async waitForTopNavToBeVisible() { - await retry.try(async () => { - const isNavVisible = await testSubjects.exists('top-nav'); - if (!isNavVisible) { - throw new Error('Local nav not visible yet'); - } - }); + // Pause the browser at a certain place for debugging + // Not meant for usage in CI, only for dev-usage + async pause() { + return this.browser.pause(); + } + + /** + * Clicks cancel button on modal + * @param overlayWillStay pass in true if your test will show multiple modals in succession + */ + async clickCancelOnModal(overlayWillStay = true) { + this.log.debug('Clicking modal cancel'); + await this.testSubjects.click('confirmModalCancelButton'); + if (!overlayWillStay) { + await this.ensureModalOverlayHidden(); } + } - async closeToast() { - const toast = await find.byCssSelector('.euiToast', 6 * defaultFindTimeout); - await toast.moveMouseTo(); - const title = await (await find.byCssSelector('.euiToastHeader__title')).getVisibleText(); + async expectConfirmModalOpenState(state: boolean) { + this.log.debug(`expectConfirmModalOpenState(${state})`); + // we use retry here instead of a simple .exists() check because the modal + // fades in/out, which takes time, and we really only care that at some point + // the modal is either open or closed + await this.retry.try(async () => { + const actualState = await this.testSubjects.exists('confirmModalCancelButton'); + expect(actualState).to.equal( + state, + state ? 'Confirm modal should be present' : 'Confirm modal should be hidden' + ); + }); + } - await find.clickByCssSelector('.euiToast__closeButton'); - return title; - } + async isChromeVisible() { + const globalNavShown = await this.globalNav.exists(); + return globalNavShown; + } - async closeToastIfExists() { - const toastShown = await find.existsByCssSelector('.euiToast'); - if (toastShown) { - try { - await find.clickByCssSelector('.euiToast__closeButton'); - } catch (err) { - // ignore errors, toast clear themselves after timeout - } - } - } + async isChromeHidden() { + const globalNavShown = await this.globalNav.exists(); + return !globalNavShown; + } - async clearAllToasts() { - const toasts = await find.allByCssSelector('.euiToast'); - for (const toastElement of toasts) { - try { - await toastElement.moveMouseTo(); - const closeBtn = await toastElement.findByCssSelector('.euiToast__closeButton'); - await closeBtn.click(); - } catch (err) { - // ignore errors, toast clear themselves after timeout - } + async waitForTopNavToBeVisible() { + await this.retry.try(async () => { + const isNavVisible = await this.testSubjects.exists('top-nav'); + if (!isNavVisible) { + throw new Error('Local nav not visible yet'); } - } + }); + } - async getJsonBodyText() { - if (await find.existsByCssSelector('a[id=rawdata-tab]', defaultFindTimeout)) { - // Firefox has 3 tabs and requires navigation to see Raw output - await find.clickByCssSelector('a[id=rawdata-tab]'); - } - const msgElements = await find.allByCssSelector('body pre'); - if (msgElements.length > 0) { - return await msgElements[0].getVisibleText(); - } else { - // Sometimes Firefox renders Timelion page without tabs and with div#json - const jsonElement = await find.byCssSelector('body div#json'); - return await jsonElement.getVisibleText(); + async closeToast() { + const toast = await this.find.byCssSelector('.euiToast', 6 * this.defaultFindTimeout); + await toast.moveMouseTo(); + const title = await (await this.find.byCssSelector('.euiToastHeader__title')).getVisibleText(); + + await this.find.clickByCssSelector('.euiToast__closeButton'); + return title; + } + + async closeToastIfExists() { + const toastShown = await this.find.existsByCssSelector('.euiToast'); + if (toastShown) { + try { + await this.find.clickByCssSelector('.euiToast__closeButton'); + } catch (err) { + // ignore errors, toast clear themselves after timeout } } + } - async getBodyText() { - const body = await find.byCssSelector('body'); - return await body.getVisibleText(); + async clearAllToasts() { + const toasts = await this.find.allByCssSelector('.euiToast'); + for (const toastElement of toasts) { + try { + await toastElement.moveMouseTo(); + const closeBtn = await toastElement.findByCssSelector('.euiToast__closeButton'); + await closeBtn.click(); + } catch (err) { + // ignore errors, toast clear themselves after timeout + } } + } - async waitForSaveModalToClose() { - log.debug('Waiting for save modal to close'); - await retry.try(async () => { - if (await testSubjects.exists('savedObjectSaveModal')) { - throw new Error('save modal still open'); - } - }); + async getJsonBodyText() { + if (await this.find.existsByCssSelector('a[id=rawdata-tab]', this.defaultFindTimeout)) { + // Firefox has 3 tabs and requires navigation to see Raw output + await this.find.clickByCssSelector('a[id=rawdata-tab]'); } - - async setFileInputPath(path: string) { - log.debug(`Setting the path '${path}' on the file input`); - const input = await find.byCssSelector('.euiFilePicker__input'); - await input.type(path); + const msgElements = await this.find.allByCssSelector('body pre'); + if (msgElements.length > 0) { + return await msgElements[0].getVisibleText(); + } else { + // Sometimes Firefox renders Timelion page without tabs and with div#json + const jsonElement = await this.find.byCssSelector('body div#json'); + return await jsonElement.getVisibleText(); } + } - async scrollKibanaBodyTop() { - await browser.setScrollToById('kibana-body', 0, 0); - } + async getBodyText() { + const body = await this.find.byCssSelector('body'); + return await body.getVisibleText(); + } - /** - * Dismiss Banner if available. - */ - async dismissBanner() { - if (await testSubjects.exists('global-banner-item')) { - const button = await find.byButtonText('Dismiss'); - await button.click(); + async waitForSaveModalToClose() { + this.log.debug('Waiting for save modal to close'); + await this.retry.try(async () => { + if (await this.testSubjects.exists('savedObjectSaveModal')) { + throw new Error('save modal still open'); } - } + }); + } - /** - * Get visible text of the Welcome Banner - */ - async getWelcomeText() { - return await testSubjects.getVisibleText('global-banner-item'); - } + async setFileInputPath(path: string) { + this.log.debug(`Setting the path '${path}' on the file input`); + const input = await this.find.byCssSelector('.euiFilePicker__input'); + await input.type(path); + } + + async scrollKibanaBodyTop() { + await this.browser.setScrollToById('kibana-body', 0, 0); + } - /** - * Clicks on an element, and validates that the desired effect has taken place - * by confirming the existence of a validator - */ - async clickAndValidate( - clickTarget: string, - validator: string, - isValidatorCssString: boolean = false, - topOffset?: number - ) { - await testSubjects.click(clickTarget, undefined, topOffset); - const validate = isValidatorCssString ? find.byCssSelector : testSubjects.exists; - await validate(validator); + /** + * Dismiss Banner if available. + */ + async dismissBanner() { + if (await this.testSubjects.exists('global-banner-item')) { + const button = await this.find.byButtonText('Dismiss'); + await button.click(); } } - return new CommonPage(); + /** + * Get visible text of the Welcome Banner + */ + async getWelcomeText() { + return await this.testSubjects.getVisibleText('global-banner-item'); + } + + /** + * Clicks on an element, and validates that the desired effect has taken place + * by confirming the existence of a validator + */ + async clickAndValidate( + clickTarget: string, + validator: string, + isValidatorCssString: boolean = false, + topOffset?: number + ) { + await this.testSubjects.click(clickTarget, undefined, topOffset); + const validate = isValidatorCssString ? this.find.byCssSelector : this.testSubjects.exists; + await validate(validator); + } } diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts index 6fb554e6d34a0c..77c87f6066e854 100644 --- a/test/functional/page_objects/console_page.ts +++ b/test/functional/page_objects/console_page.ts @@ -7,102 +7,98 @@ */ import { Key } from 'selenium-webdriver'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; -export function ConsolePageProvider({ getService }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - const find = getService('find'); +export class ConsolePageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); + private readonly find = this.ctx.getService('find'); - class ConsolePage { - public async getVisibleTextFromAceEditor(editor: WebElementWrapper) { - const lines = await editor.findAllByClassName('ace_line_group'); - const linesText = await Promise.all(lines.map(async (line) => await line.getVisibleText())); - return linesText.join('\n'); - } + public async getVisibleTextFromAceEditor(editor: WebElementWrapper) { + const lines = await editor.findAllByClassName('ace_line_group'); + const linesText = await Promise.all(lines.map(async (line) => await line.getVisibleText())); + return linesText.join('\n'); + } - public async getRequestEditor() { - return await testSubjects.find('request-editor'); - } + public async getRequestEditor() { + return await this.testSubjects.find('request-editor'); + } - public async getRequest() { - const requestEditor = await this.getRequestEditor(); - return await this.getVisibleTextFromAceEditor(requestEditor); - } + public async getRequest() { + const requestEditor = await this.getRequestEditor(); + return await this.getVisibleTextFromAceEditor(requestEditor); + } - public async getResponse() { - const responseEditor = await testSubjects.find('response-editor'); - return await this.getVisibleTextFromAceEditor(responseEditor); - } + public async getResponse() { + const responseEditor = await this.testSubjects.find('response-editor'); + return await this.getVisibleTextFromAceEditor(responseEditor); + } - public async clickPlay() { - await testSubjects.click('sendRequestButton'); - } + public async clickPlay() { + await this.testSubjects.click('sendRequestButton'); + } - public async collapseHelp() { - await testSubjects.click('help-close-button'); - } + public async collapseHelp() { + await this.testSubjects.click('help-close-button'); + } - public async openSettings() { - await testSubjects.click('consoleSettingsButton'); - } + public async openSettings() { + await this.testSubjects.click('consoleSettingsButton'); + } - public async setFontSizeSetting(newSize: number) { - await this.openSettings(); + public async setFontSizeSetting(newSize: number) { + await this.openSettings(); - // while the settings form opens/loads this may fail, so retry for a while - await retry.try(async () => { - const fontSizeInput = await testSubjects.find('setting-font-size-input'); - await fontSizeInput.clearValue({ withJS: true }); - await fontSizeInput.click(); - await fontSizeInput.type(String(newSize)); - }); + // while the settings form opens/loads this may fail, so retry for a while + await this.retry.try(async () => { + const fontSizeInput = await this.testSubjects.find('setting-font-size-input'); + await fontSizeInput.clearValue({ withJS: true }); + await fontSizeInput.click(); + await fontSizeInput.type(String(newSize)); + }); - await testSubjects.click('settings-save-button'); - } + await this.testSubjects.click('settings-save-button'); + } - public async getFontSize(editor: WebElementWrapper) { - const aceLine = await editor.findByClassName('ace_line'); - return await aceLine.getComputedStyle('font-size'); - } + public async getFontSize(editor: WebElementWrapper) { + const aceLine = await editor.findByClassName('ace_line'); + return await aceLine.getComputedStyle('font-size'); + } - public async getRequestFontSize() { - return await this.getFontSize(await this.getRequestEditor()); - } + public async getRequestFontSize() { + return await this.getFontSize(await this.getRequestEditor()); + } - public async getEditor() { - return testSubjects.find('console-application'); - } + public async getEditor() { + return this.testSubjects.find('console-application'); + } - public async dismissTutorial() { - try { - const closeButton = await testSubjects.find('help-close-button'); - await closeButton.click(); - } catch (e) { - // Ignore because it is probably not there. - } + public async dismissTutorial() { + try { + const closeButton = await this.testSubjects.find('help-close-button'); + await closeButton.click(); + } catch (e) { + // Ignore because it is probably not there. } + } - public async promptAutocomplete() { - // This focusses the cursor on the bottom of the text area - const editor = await this.getEditor(); - const content = await editor.findByCssSelector('.ace_content'); - await content.click(); - const textArea = await testSubjects.find('console-textarea'); - // There should be autocomplete for this on all license levels - await textArea.pressKeys('\nGET s'); - await textArea.pressKeys([Key.CONTROL, Key.SPACE]); - } + public async promptAutocomplete() { + // This focusses the cursor on the bottom of the text area + const editor = await this.getEditor(); + const content = await editor.findByCssSelector('.ace_content'); + await content.click(); + const textArea = await this.testSubjects.find('console-textarea'); + // There should be autocomplete for this on all license levels + await textArea.pressKeys('\nGET s'); + await textArea.pressKeys([Key.CONTROL, Key.SPACE]); + } - public async hasAutocompleter(): Promise { - try { - return Boolean(await find.byCssSelector('.ace_autocomplete')); - } catch (e) { - return false; - } + public async hasAutocompleter(): Promise { + try { + return Boolean(await this.find.byCssSelector('.ace_autocomplete')); + } catch (e) { + return false; } } - - return new ConsolePage(); } diff --git a/test/functional/page_objects/context_page.ts b/test/functional/page_objects/context_page.ts index b758423d9346dd..05ea89cb65b3d0 100644 --- a/test/functional/page_objects/context_page.ts +++ b/test/functional/page_objects/context_page.ts @@ -8,93 +8,91 @@ import rison from 'rison-node'; import { getUrl } from '@kbn/test'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; const DEFAULT_INITIAL_STATE = { columns: ['@message'], }; -export function ContextPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const browser = getService('browser'); - const config = getService('config'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['header', 'common']); - const log = getService('log'); +export class ContextPageObject extends FtrService { + private readonly browser = this.ctx.getService('browser'); + private readonly config = this.ctx.getService('config'); + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly header = this.ctx.getPageObject('header'); + private readonly common = this.ctx.getPageObject('common'); + private readonly log = this.ctx.getService('log'); - class ContextPage { - public async navigateTo(indexPattern: string, anchorId: string, overrideInitialState = {}) { - const initialState = rison.encode({ - ...DEFAULT_INITIAL_STATE, - ...overrideInitialState, - }); - const appUrl = getUrl.noAuth(config.get('servers.kibana'), { - ...config.get('apps.context'), - hash: `${config.get('apps.context.hash')}/${indexPattern}/${anchorId}?_a=${initialState}`, - }); + public async navigateTo(indexPattern: string, anchorId: string, overrideInitialState = {}) { + const initialState = rison.encode({ + ...DEFAULT_INITIAL_STATE, + ...overrideInitialState, + }); + const contextHash = this.config.get('apps.context.hash'); + const appUrl = getUrl.noAuth(this.config.get('servers.kibana'), { + ...this.config.get('apps.context'), + hash: `${contextHash}/${indexPattern}/${anchorId}?_a=${initialState}`, + }); - log.debug(`browser.get(${appUrl})`); + this.log.debug(`browser.get(${appUrl})`); - await browser.get(appUrl); - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await this.waitUntilContextLoadingHasFinished(); - // For lack of a better way, using a sleep to ensure page is loaded before proceeding - await PageObjects.common.sleep(1000); - } - - public async getPredecessorCountPicker() { - return await testSubjects.find('predecessorsCountPicker'); - } + await this.browser.get(appUrl); + await this.header.awaitGlobalLoadingIndicatorHidden(); + await this.waitUntilContextLoadingHasFinished(); + // For lack of a better way, using a sleep to ensure page is loaded before proceeding + await this.common.sleep(1000); + } - public async getSuccessorCountPicker() { - return await testSubjects.find('successorsCountPicker'); - } + public async getPredecessorCountPicker() { + return await this.testSubjects.find('predecessorsCountPicker'); + } - public async getPredecessorLoadMoreButton() { - return await testSubjects.find('predecessorsLoadMoreButton'); - } + public async getSuccessorCountPicker() { + return await this.testSubjects.find('successorsCountPicker'); + } - public async getSuccessorLoadMoreButton() { - return await testSubjects.find('successorsLoadMoreButton'); - } + public async getPredecessorLoadMoreButton() { + return await this.testSubjects.find('predecessorsLoadMoreButton'); + } - public async clickPredecessorLoadMoreButton() { - log.debug('Click Predecessor Load More Button'); - await retry.try(async () => { - const predecessorButton = await this.getPredecessorLoadMoreButton(); - await predecessorButton.click(); - }); - await this.waitUntilContextLoadingHasFinished(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async getSuccessorLoadMoreButton() { + return await this.testSubjects.find('successorsLoadMoreButton'); + } - public async clickSuccessorLoadMoreButton() { - log.debug('Click Successor Load More Button'); - await retry.try(async () => { - const sucessorButton = await this.getSuccessorLoadMoreButton(); - await sucessorButton.click(); - }); - await this.waitUntilContextLoadingHasFinished(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async clickPredecessorLoadMoreButton() { + this.log.debug('Click Predecessor Load More Button'); + await this.retry.try(async () => { + const predecessorButton = await this.getPredecessorLoadMoreButton(); + await predecessorButton.click(); + }); + await this.waitUntilContextLoadingHasFinished(); + await this.header.waitUntilLoadingHasFinished(); + } - public async waitUntilContextLoadingHasFinished() { - return await retry.try(async () => { - const successorLoadMoreButton = await this.getSuccessorLoadMoreButton(); - const predecessorLoadMoreButton = await this.getPredecessorLoadMoreButton(); - if ( - !( - (await successorLoadMoreButton.isEnabled()) && - (await successorLoadMoreButton.isDisplayed()) && - (await predecessorLoadMoreButton.isEnabled()) && - (await predecessorLoadMoreButton.isDisplayed()) - ) - ) { - throw new Error('loading context rows'); - } - }); - } + public async clickSuccessorLoadMoreButton() { + this.log.debug('Click Successor Load More Button'); + await this.retry.try(async () => { + const sucessorButton = await this.getSuccessorLoadMoreButton(); + await sucessorButton.click(); + }); + await this.waitUntilContextLoadingHasFinished(); + await this.header.waitUntilLoadingHasFinished(); } - return new ContextPage(); + public async waitUntilContextLoadingHasFinished() { + return await this.retry.try(async () => { + const successorLoadMoreButton = await this.getSuccessorLoadMoreButton(); + const predecessorLoadMoreButton = await this.getPredecessorLoadMoreButton(); + if ( + !( + (await successorLoadMoreButton.isEnabled()) && + (await successorLoadMoreButton.isDisplayed()) && + (await predecessorLoadMoreButton.isEnabled()) && + (await predecessorLoadMoreButton.isDisplayed()) + ) + ) { + throw new Error('loading context rows'); + } + }); + } } diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index ba75ab75cc6e89..194f0936274e5e 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -9,668 +9,667 @@ export const PIE_CHART_VIS_NAME = 'Visualization PieChart'; export const AREA_CHART_VIS_NAME = 'Visualization漢字 AreaChart'; export const LINE_CHART_VIS_NAME = 'Visualization漢字 LineChart'; -import { FtrProviderContext } from '../ftr_provider_context'; - -export function DashboardPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const find = getService('find'); - const retry = getService('retry'); - const browser = getService('browser'); - const globalNav = getService('globalNav'); - const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); - const testSubjects = getService('testSubjects'); - const dashboardAddPanel = getService('dashboardAddPanel'); - const renderable = getService('renderable'); - const listingTable = getService('listingTable'); - const elasticChart = getService('elasticChart'); - const PageObjects = getPageObjects(['common', 'header', 'visualize', 'discover']); - - interface SaveDashboardOptions { - /** - * @default true - */ - waitDialogIsClosed?: boolean; - exitFromEditMode?: boolean; - needsConfirm?: boolean; - storeTimeWithDashboard?: boolean; - saveAsNew?: boolean; - tags?: string[]; - } - - class DashboardPage { - async initTests({ kibanaIndex = 'dashboard/legacy', defaultIndex = 'logstash-*' } = {}) { - log.debug('load kibana index with visualizations and log data'); - await esArchiver.load(kibanaIndex); - await kibanaServer.uiSettings.replace({ defaultIndex }); - await PageObjects.common.navigateToApp('dashboard'); - } - - public async preserveCrossAppState() { - const url = await browser.getCurrentUrl(); - await browser.get(url, false); - await PageObjects.header.waitUntilLoadingHasFinished(); - } +import { FtrService } from '../ftr_provider_context'; + +interface SaveDashboardOptions { + /** + * @default true + */ + waitDialogIsClosed?: boolean; + exitFromEditMode?: boolean; + needsConfirm?: boolean; + storeTimeWithDashboard?: boolean; + saveAsNew?: boolean; + tags?: string[]; +} - public async clickFullScreenMode() { - log.debug(`clickFullScreenMode`); - await testSubjects.click('dashboardFullScreenMode'); - await testSubjects.exists('exitFullScreenModeLogo'); - await this.waitForRenderComplete(); - } +export class DashboardPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly find = this.ctx.getService('find'); + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly globalNav = this.ctx.getService('globalNav'); + private readonly esArchiver = this.ctx.getService('esArchiver'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly dashboardAddPanel = this.ctx.getService('dashboardAddPanel'); + private readonly renderable = this.ctx.getService('renderable'); + private readonly listingTable = this.ctx.getService('listingTable'); + private readonly elasticChart = this.ctx.getService('elasticChart'); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); + private readonly visualize = this.ctx.getPageObject('visualize'); + private readonly discover = this.ctx.getPageObject('discover'); + + async initTests({ kibanaIndex = 'dashboard/legacy', defaultIndex = 'logstash-*' } = {}) { + this.log.debug('load kibana index with visualizations and log data'); + await this.esArchiver.load(kibanaIndex); + await this.kibanaServer.uiSettings.replace({ defaultIndex }); + await this.common.navigateToApp('dashboard'); + } - public async exitFullScreenMode() { - log.debug(`exitFullScreenMode`); - const logoButton = await this.getExitFullScreenLogoButton(); - await logoButton.moveMouseTo(); - await this.clickExitFullScreenTextButton(); - } + public async preserveCrossAppState() { + const url = await this.browser.getCurrentUrl(); + await this.browser.get(url, false); + await this.header.waitUntilLoadingHasFinished(); + } - public async fullScreenModeMenuItemExists() { - return await testSubjects.exists('dashboardFullScreenMode'); - } + public async clickFullScreenMode() { + this.log.debug(`clickFullScreenMode`); + await this.testSubjects.click('dashboardFullScreenMode'); + await this.testSubjects.exists('exitFullScreenModeLogo'); + await this.waitForRenderComplete(); + } - public async exitFullScreenTextButtonExists() { - return await testSubjects.exists('exitFullScreenModeText'); - } + public async exitFullScreenMode() { + this.log.debug(`exitFullScreenMode`); + const logoButton = await this.getExitFullScreenLogoButton(); + await logoButton.moveMouseTo(); + await this.clickExitFullScreenTextButton(); + } - public async getExitFullScreenTextButton() { - return await testSubjects.find('exitFullScreenModeText'); - } + public async fullScreenModeMenuItemExists() { + return await this.testSubjects.exists('dashboardFullScreenMode'); + } - public async exitFullScreenLogoButtonExists() { - return await testSubjects.exists('exitFullScreenModeLogo'); - } + public async exitFullScreenTextButtonExists() { + return await this.testSubjects.exists('exitFullScreenModeText'); + } - public async getExitFullScreenLogoButton() { - return await testSubjects.find('exitFullScreenModeLogo'); - } + public async getExitFullScreenTextButton() { + return await this.testSubjects.find('exitFullScreenModeText'); + } - public async clickExitFullScreenLogoButton() { - await testSubjects.click('exitFullScreenModeLogo'); - await this.waitForRenderComplete(); - } + public async exitFullScreenLogoButtonExists() { + return await this.testSubjects.exists('exitFullScreenModeLogo'); + } - public async clickExitFullScreenTextButton() { - await testSubjects.click('exitFullScreenModeText'); - await this.waitForRenderComplete(); - } + public async getExitFullScreenLogoButton() { + return await this.testSubjects.find('exitFullScreenModeLogo'); + } - public async getDashboardIdFromCurrentUrl() { - const currentUrl = await browser.getCurrentUrl(); - const id = this.getDashboardIdFromUrl(currentUrl); + public async clickExitFullScreenLogoButton() { + await this.testSubjects.click('exitFullScreenModeLogo'); + await this.waitForRenderComplete(); + } - log.debug(`Dashboard id extracted from ${currentUrl} is ${id}`); + public async clickExitFullScreenTextButton() { + await this.testSubjects.click('exitFullScreenModeText'); + await this.waitForRenderComplete(); + } - return id; - } + public async getDashboardIdFromCurrentUrl() { + const currentUrl = await this.browser.getCurrentUrl(); + const id = this.getDashboardIdFromUrl(currentUrl); - public getDashboardIdFromUrl(url: string) { - const urlSubstring = '#/view/'; - const startOfIdIndex = url.indexOf(urlSubstring) + urlSubstring.length; - const endIndex = url.indexOf('?'); - const id = url.substring(startOfIdIndex, endIndex < 0 ? url.length : endIndex); - return id; - } + this.log.debug(`Dashboard id extracted from ${currentUrl} is ${id}`); - public async expectUnsavedChangesListingExists(title: string) { - log.debug(`Expect Unsaved Changes Listing Exists for `, title); - await testSubjects.existOrFail(`edit-unsaved-${title.split(' ').join('-')}`); - } + return id; + } - public async expectUnsavedChangesDoesNotExist(title: string) { - log.debug(`Expect Unsaved Changes Listing Does Not Exist for `, title); - await testSubjects.missingOrFail(`edit-unsaved-${title.split(' ').join('-')}`); - } + public getDashboardIdFromUrl(url: string) { + const urlSubstring = '#/view/'; + const startOfIdIndex = url.indexOf(urlSubstring) + urlSubstring.length; + const endIndex = url.indexOf('?'); + const id = url.substring(startOfIdIndex, endIndex < 0 ? url.length : endIndex); + return id; + } - public async clickUnsavedChangesContinueEditing(title: string) { - log.debug(`Click Unsaved Changes Continue Editing `, title); - await testSubjects.existOrFail(`edit-unsaved-${title.split(' ').join('-')}`); - await testSubjects.click(`edit-unsaved-${title.split(' ').join('-')}`); - } + public async expectUnsavedChangesListingExists(title: string) { + this.log.debug(`Expect Unsaved Changes Listing Exists for `, title); + await this.testSubjects.existOrFail(`edit-unsaved-${title.split(' ').join('-')}`); + } - public async clickUnsavedChangesDiscard(title: string, confirmDiscard = true) { - log.debug(`Click Unsaved Changes Discard for `, title); - await testSubjects.existOrFail(`discard-unsaved-${title.split(' ').join('-')}`); - await testSubjects.click(`discard-unsaved-${title.split(' ').join('-')}`); - if (confirmDiscard) { - await PageObjects.common.clickConfirmOnModal(); - } else { - await PageObjects.common.clickCancelOnModal(); - } - } + public async expectUnsavedChangesDoesNotExist(title: string) { + this.log.debug(`Expect Unsaved Changes Listing Does Not Exist for `, title); + await this.testSubjects.missingOrFail(`edit-unsaved-${title.split(' ').join('-')}`); + } - /** - * Returns true if already on the dashboard landing page (that page doesn't have a link to itself). - * @returns {Promise} - */ - public async onDashboardLandingPage() { - log.debug(`onDashboardLandingPage`); - return await listingTable.onListingPage('dashboard'); - } + public async clickUnsavedChangesContinueEditing(title: string) { + this.log.debug(`Click Unsaved Changes Continue Editing `, title); + await this.testSubjects.existOrFail(`edit-unsaved-${title.split(' ').join('-')}`); + await this.testSubjects.click(`edit-unsaved-${title.split(' ').join('-')}`); + } - public async expectExistsDashboardLandingPage() { - log.debug(`expectExistsDashboardLandingPage`); - await testSubjects.existOrFail('dashboardLandingPage'); + public async clickUnsavedChangesDiscard(title: string, confirmDiscard = true) { + this.log.debug(`Click Unsaved Changes Discard for `, title); + await this.testSubjects.existOrFail(`discard-unsaved-${title.split(' ').join('-')}`); + await this.testSubjects.click(`discard-unsaved-${title.split(' ').join('-')}`); + if (confirmDiscard) { + await this.common.clickConfirmOnModal(); + } else { + await this.common.clickCancelOnModal(); } + } - public async clickDashboardBreadcrumbLink() { - log.debug('clickDashboardBreadcrumbLink'); - await testSubjects.click('breadcrumb dashboardListingBreadcrumb first'); - } + /** + * Returns true if already on the dashboard landing page (that page doesn't have a link to itself). + * @returns {Promise} + */ + public async onDashboardLandingPage() { + this.log.debug(`onDashboardLandingPage`); + return await this.listingTable.onListingPage('dashboard'); + } - public async expectOnDashboard(dashboardTitle: string) { - await retry.waitFor( - 'last breadcrumb to have dashboard title', - async () => (await globalNav.getLastBreadcrumb()) === dashboardTitle - ); - } + public async expectExistsDashboardLandingPage() { + this.log.debug(`expectExistsDashboardLandingPage`); + await this.testSubjects.existOrFail('dashboardLandingPage'); + } - public async gotoDashboardLandingPage(ignorePageLeaveWarning = true) { - log.debug('gotoDashboardLandingPage'); - const onPage = await this.onDashboardLandingPage(); - if (!onPage) { - await this.clickDashboardBreadcrumbLink(); - await retry.try(async () => { - const warning = await testSubjects.exists('confirmModalTitleText'); - if (warning) { - await testSubjects.click( - ignorePageLeaveWarning ? 'confirmModalConfirmButton' : 'confirmModalCancelButton' - ); - } - }); - await this.expectExistsDashboardLandingPage(); - } - } + public async clickDashboardBreadcrumbLink() { + this.log.debug('clickDashboardBreadcrumbLink'); + await this.testSubjects.click('breadcrumb dashboardListingBreadcrumb first'); + } - public async clickClone() { - log.debug('Clicking clone'); - await testSubjects.click('dashboardClone'); - } + public async expectOnDashboard(dashboardTitle: string) { + await this.retry.waitFor( + 'last breadcrumb to have dashboard title', + async () => (await this.globalNav.getLastBreadcrumb()) === dashboardTitle + ); + } - public async getCloneTitle() { - return await testSubjects.getAttribute('clonedDashboardTitle', 'value'); + public async gotoDashboardLandingPage(ignorePageLeaveWarning = true) { + this.log.debug('gotoDashboardLandingPage'); + const onPage = await this.onDashboardLandingPage(); + if (!onPage) { + await this.clickDashboardBreadcrumbLink(); + await this.retry.try(async () => { + const warning = await this.testSubjects.exists('confirmModalTitleText'); + if (warning) { + await this.testSubjects.click( + ignorePageLeaveWarning ? 'confirmModalConfirmButton' : 'confirmModalCancelButton' + ); + } + }); + await this.expectExistsDashboardLandingPage(); } + } - public async confirmClone() { - log.debug('Confirming clone'); - await testSubjects.click('cloneConfirmButton'); - } + public async clickClone() { + this.log.debug('Clicking clone'); + await this.testSubjects.click('dashboardClone'); + } - public async cancelClone() { - log.debug('Canceling clone'); - await testSubjects.click('cloneCancelButton'); - } + public async getCloneTitle() { + return await this.testSubjects.getAttribute('clonedDashboardTitle', 'value'); + } - public async setClonedDashboardTitle(title: string) { - await testSubjects.setValue('clonedDashboardTitle', title); - } + public async confirmClone() { + this.log.debug('Confirming clone'); + await this.testSubjects.click('cloneConfirmButton'); + } - /** - * Asserts that the duplicate title warning is either displayed or not displayed. - * @param { displayed: boolean } - */ - public async expectDuplicateTitleWarningDisplayed({ displayed = true }) { - if (displayed) { - await testSubjects.existOrFail('titleDupicateWarnMsg'); - } else { - await testSubjects.missingOrFail('titleDupicateWarnMsg'); - } - } + public async cancelClone() { + this.log.debug('Canceling clone'); + await this.testSubjects.click('cloneCancelButton'); + } - /** - * Asserts that the toolbar pagination (count and arrows) is either displayed or not displayed. + public async setClonedDashboardTitle(title: string) { + await this.testSubjects.setValue('clonedDashboardTitle', title); + } - */ - public async expectToolbarPaginationDisplayed() { - const isLegacyDefault = PageObjects.discover.useLegacyTable(); - if (isLegacyDefault) { - const subjects = ['btnPrevPage', 'btnNextPage', 'toolBarPagerText']; - await Promise.all(subjects.map(async (subj) => await testSubjects.existOrFail(subj))); - } else { - const subjects = ['pagination-button-previous', 'pagination-button-next']; + /** + * Asserts that the duplicate title warning is either displayed or not displayed. + * @param { displayed: boolean } + */ + public async expectDuplicateTitleWarningDisplayed({ displayed = true }) { + if (displayed) { + await this.testSubjects.existOrFail('titleDupicateWarnMsg'); + } else { + await this.testSubjects.missingOrFail('titleDupicateWarnMsg'); + } + } - await Promise.all(subjects.map(async (subj) => await testSubjects.existOrFail(subj))); - const paginationListExists = await find.existsByCssSelector('.euiPagination__list'); - if (!paginationListExists) { - throw new Error(`expected discover data grid pagination list to exist`); - } + /** + * Asserts that the toolbar pagination (count and arrows) is either displayed or not displayed. + + */ + public async expectToolbarPaginationDisplayed() { + const isLegacyDefault = this.discover.useLegacyTable(); + if (isLegacyDefault) { + const subjects = ['btnPrevPage', 'btnNextPage', 'toolBarPagerText']; + await Promise.all(subjects.map(async (subj) => await this.testSubjects.existOrFail(subj))); + } else { + const subjects = ['pagination-button-previous', 'pagination-button-next']; + + await Promise.all(subjects.map(async (subj) => await this.testSubjects.existOrFail(subj))); + const paginationListExists = await this.find.existsByCssSelector('.euiPagination__list'); + if (!paginationListExists) { + throw new Error(`expected discover data grid pagination list to exist`); } } + } - public async switchToEditMode() { - log.debug('Switching to edit mode'); - await testSubjects.click('dashboardEditMode'); - // wait until the count of dashboard panels equals the count of toggle menu icons - await retry.waitFor('in edit mode', async () => { - const panels = await testSubjects.findAll('embeddablePanel', 2500); - const menuIcons = await testSubjects.findAll('embeddablePanelToggleMenuIcon', 2500); - return panels.length === menuIcons.length; - }); - } + public async switchToEditMode() { + this.log.debug('Switching to edit mode'); + await this.testSubjects.click('dashboardEditMode'); + // wait until the count of dashboard panels equals the count of toggle menu icons + await this.retry.waitFor('in edit mode', async () => { + const panels = await this.testSubjects.findAll('embeddablePanel', 2500); + const menuIcons = await this.testSubjects.findAll('embeddablePanelToggleMenuIcon', 2500); + return panels.length === menuIcons.length; + }); + } - public async getIsInViewMode() { - log.debug('getIsInViewMode'); - return await testSubjects.exists('dashboardEditMode'); - } + public async getIsInViewMode() { + this.log.debug('getIsInViewMode'); + return await this.testSubjects.exists('dashboardEditMode'); + } - public async clickCancelOutOfEditMode(accept = true) { - log.debug('clickCancelOutOfEditMode'); - await testSubjects.click('dashboardViewOnlyMode'); - if (accept) { - const confirmation = await testSubjects.exists('dashboardDiscardConfirmKeep'); - if (confirmation) { - await testSubjects.click('dashboardDiscardConfirmKeep'); - } + public async clickCancelOutOfEditMode(accept = true) { + this.log.debug('clickCancelOutOfEditMode'); + await this.testSubjects.click('dashboardViewOnlyMode'); + if (accept) { + const confirmation = await this.testSubjects.exists('dashboardDiscardConfirmKeep'); + if (confirmation) { + await this.testSubjects.click('dashboardDiscardConfirmKeep'); } } + } - public async clickDiscardChanges(accept = true) { - log.debug('clickDiscardChanges'); - await testSubjects.click('dashboardViewOnlyMode'); - if (accept) { - const confirmation = await testSubjects.exists('dashboardDiscardConfirmDiscard'); - if (confirmation) { - await testSubjects.click('dashboardDiscardConfirmDiscard'); - } + public async clickDiscardChanges(accept = true) { + this.log.debug('clickDiscardChanges'); + await this.testSubjects.click('dashboardViewOnlyMode'); + if (accept) { + const confirmation = await this.testSubjects.exists('dashboardDiscardConfirmDiscard'); + if (confirmation) { + await this.testSubjects.click('dashboardDiscardConfirmDiscard'); } } + } - public async clickQuickSave() { - await this.expectQuickSaveButtonEnabled(); - log.debug('clickQuickSave'); - await testSubjects.click('dashboardQuickSaveMenuItem'); - } - - public async clickNewDashboard(continueEditing = false) { - await listingTable.clickNewButton('createDashboardPromptButton'); - if (await testSubjects.exists('dashboardCreateConfirm')) { - if (continueEditing) { - await testSubjects.click('dashboardCreateConfirmContinue'); - } else { - await testSubjects.click('dashboardCreateConfirmStartOver'); - } - } - // make sure the dashboard page is shown - await this.waitForRenderComplete(); - } + public async clickQuickSave() { + await this.expectQuickSaveButtonEnabled(); + this.log.debug('clickQuickSave'); + await this.testSubjects.click('dashboardQuickSaveMenuItem'); + } - public async clickNewDashboardExpectWarning(continueEditing = false) { - await listingTable.clickNewButton('createDashboardPromptButton'); - await testSubjects.existOrFail('dashboardCreateConfirm'); + public async clickNewDashboard(continueEditing = false) { + await this.listingTable.clickNewButton('createDashboardPromptButton'); + if (await this.testSubjects.exists('dashboardCreateConfirm')) { if (continueEditing) { - await testSubjects.click('dashboardCreateConfirmContinue'); + await this.testSubjects.click('dashboardCreateConfirmContinue'); } else { - await testSubjects.click('dashboardCreateConfirmStartOver'); + await this.testSubjects.click('dashboardCreateConfirmStartOver'); } - // make sure the dashboard page is shown - await this.waitForRenderComplete(); - } - - public async clickCreateDashboardPrompt() { - await testSubjects.click('createDashboardPromptButton'); } + // make sure the dashboard page is shown + await this.waitForRenderComplete(); + } - public async getCreateDashboardPromptExists() { - return await testSubjects.exists('createDashboardPromptButton'); + public async clickNewDashboardExpectWarning(continueEditing = false) { + await this.listingTable.clickNewButton('createDashboardPromptButton'); + await this.testSubjects.existOrFail('dashboardCreateConfirm'); + if (continueEditing) { + await this.testSubjects.click('dashboardCreateConfirmContinue'); + } else { + await this.testSubjects.click('dashboardCreateConfirmStartOver'); } + // make sure the dashboard page is shown + await this.waitForRenderComplete(); + } - public async isOptionsOpen() { - log.debug('isOptionsOpen'); - return await testSubjects.exists('dashboardOptionsMenu'); - } + public async clickCreateDashboardPrompt() { + await this.testSubjects.click('createDashboardPromptButton'); + } - public async openOptions() { - log.debug('openOptions'); - const isOpen = await this.isOptionsOpen(); - if (!isOpen) { - return await testSubjects.click('dashboardOptionsButton'); - } - } + public async getCreateDashboardPromptExists() { + return await this.testSubjects.exists('createDashboardPromptButton'); + } - // avoids any 'Object with id x not found' errors when switching tests. - public async clearSavedObjectsFromAppLinks() { - await PageObjects.header.clickVisualize(); - await PageObjects.visualize.gotoLandingPage(); - await PageObjects.header.clickDashboard(); - await this.gotoDashboardLandingPage(); - } + public async isOptionsOpen() { + this.log.debug('isOptionsOpen'); + return await this.testSubjects.exists('dashboardOptionsMenu'); + } - public async isMarginsOn() { - log.debug('isMarginsOn'); - await this.openOptions(); - return await testSubjects.getAttribute('dashboardMarginsCheckbox', 'checked'); + public async openOptions() { + this.log.debug('openOptions'); + const isOpen = await this.isOptionsOpen(); + if (!isOpen) { + return await this.testSubjects.click('dashboardOptionsButton'); } + } - public async useMargins(on = true) { - await this.openOptions(); - const isMarginsOn = await this.isMarginsOn(); - if (isMarginsOn !== 'on') { - return await testSubjects.click('dashboardMarginsCheckbox'); - } - } + // avoids any 'Object with id x not found' errors when switching tests. + public async clearSavedObjectsFromAppLinks() { + await this.header.clickVisualize(); + await this.visualize.gotoLandingPage(); + await this.header.clickDashboard(); + await this.gotoDashboardLandingPage(); + } - public async isColorSyncOn() { - log.debug('isColorSyncOn'); - await this.openOptions(); - return await testSubjects.getAttribute('dashboardSyncColorsCheckbox', 'checked'); - } + public async isMarginsOn() { + this.log.debug('isMarginsOn'); + await this.openOptions(); + return await this.testSubjects.getAttribute('dashboardMarginsCheckbox', 'checked'); + } - public async useColorSync(on = true) { - await this.openOptions(); - const isColorSyncOn = await this.isColorSyncOn(); - if (isColorSyncOn !== 'on') { - return await testSubjects.click('dashboardSyncColorsCheckbox'); - } + public async useMargins(on = true) { + await this.openOptions(); + const isMarginsOn = await this.isMarginsOn(); + if (isMarginsOn !== 'on') { + return await this.testSubjects.click('dashboardMarginsCheckbox'); } + } - public async gotoDashboardEditMode(dashboardName: string) { - await this.loadSavedDashboard(dashboardName); - await this.switchToEditMode(); - } + public async isColorSyncOn() { + this.log.debug('isColorSyncOn'); + await this.openOptions(); + return await this.testSubjects.getAttribute('dashboardSyncColorsCheckbox', 'checked'); + } - public async renameDashboard(dashboardName: string) { - log.debug(`Naming dashboard ` + dashboardName); - await testSubjects.click('dashboardRenameButton'); - await testSubjects.setValue('savedObjectTitle', dashboardName); + public async useColorSync(on = true) { + await this.openOptions(); + const isColorSyncOn = await this.isColorSyncOn(); + if (isColorSyncOn !== 'on') { + return await this.testSubjects.click('dashboardSyncColorsCheckbox'); } + } - /** - * Save the current dashboard with the specified name and options and - * verify that the save was successful, close the toast and return the - * toast message - * - * @param dashboardName {String} - * @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean, needsConfirm: false, waitDialogIsClosed: boolean }} - */ - public async saveDashboard( - dashboardName: string, - saveOptions: SaveDashboardOptions = { waitDialogIsClosed: true, exitFromEditMode: true } - ) { - await retry.try(async () => { - await this.enterDashboardTitleAndClickSave(dashboardName, saveOptions); - - if (saveOptions.needsConfirm) { - await this.ensureDuplicateTitleCallout(); - await this.clickSave(); - } + public async gotoDashboardEditMode(dashboardName: string) { + await this.loadSavedDashboard(dashboardName); + await this.switchToEditMode(); + } - // Confirm that the Dashboard has actually been saved - await testSubjects.existOrFail('saveDashboardSuccess'); - }); - const message = await PageObjects.common.closeToast(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.common.waitForSaveModalToClose(); + public async renameDashboard(dashboardName: string) { + this.log.debug(`Naming dashboard ` + dashboardName); + await this.testSubjects.click('dashboardRenameButton'); + await this.testSubjects.setValue('savedObjectTitle', dashboardName); + } - const isInViewMode = await testSubjects.exists('dashboardEditMode'); - if (saveOptions.exitFromEditMode && !isInViewMode) { - await this.clickCancelOutOfEditMode(); + /** + * Save the current dashboard with the specified name and options and + * verify that the save was successful, close the toast and return the + * toast message + * + * @param dashboardName {String} + * @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean, needsConfirm: false, waitDialogIsClosed: boolean }} + */ + public async saveDashboard( + dashboardName: string, + saveOptions: SaveDashboardOptions = { waitDialogIsClosed: true, exitFromEditMode: true } + ) { + await this.retry.try(async () => { + await this.enterDashboardTitleAndClickSave(dashboardName, saveOptions); + + if (saveOptions.needsConfirm) { + await this.ensureDuplicateTitleCallout(); + await this.clickSave(); } - await PageObjects.header.waitUntilLoadingHasFinished(); - return message; - } + // Confirm that the Dashboard has actually been saved + await this.testSubjects.existOrFail('saveDashboardSuccess'); + }); + const message = await this.common.closeToast(); + await this.header.waitUntilLoadingHasFinished(); + await this.common.waitForSaveModalToClose(); - public async cancelSave() { - log.debug('Canceling save'); - await testSubjects.click('saveCancelButton'); + const isInViewMode = await this.testSubjects.exists('dashboardEditMode'); + if (saveOptions.exitFromEditMode && !isInViewMode) { + await this.clickCancelOutOfEditMode(); } + await this.header.waitUntilLoadingHasFinished(); - public async clickSave() { - log.debug('DashboardPage.clickSave'); - await testSubjects.click('confirmSaveSavedObjectButton'); - } + return message; + } - /** - * - * @param dashboardTitle {String} - * @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean, waitDialogIsClosed: boolean}} - */ - public async enterDashboardTitleAndClickSave( - dashboardTitle: string, - saveOptions: SaveDashboardOptions = { waitDialogIsClosed: true } - ) { - await testSubjects.click('dashboardSaveMenuItem'); - const modalDialog = await testSubjects.find('savedObjectSaveModal'); - - log.debug('entering new title'); - await testSubjects.setValue('savedObjectTitle', dashboardTitle); - - if (saveOptions.storeTimeWithDashboard !== undefined) { - await this.setStoreTimeWithDashboard(saveOptions.storeTimeWithDashboard); - } + public async cancelSave() { + this.log.debug('Canceling save'); + await this.testSubjects.click('saveCancelButton'); + } - const saveAsNewCheckboxExists = await testSubjects.exists('saveAsNewCheckbox'); - if (saveAsNewCheckboxExists) { - await this.setSaveAsNewCheckBox(Boolean(saveOptions.saveAsNew)); - } + public async clickSave() { + this.log.debug('DashboardPage.clickSave'); + await this.testSubjects.click('confirmSaveSavedObjectButton'); + } - if (saveOptions.tags) { - await this.selectDashboardTags(saveOptions.tags); - } + /** + * + * @param dashboardTitle {String} + * @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean, waitDialogIsClosed: boolean}} + */ + public async enterDashboardTitleAndClickSave( + dashboardTitle: string, + saveOptions: SaveDashboardOptions = { waitDialogIsClosed: true } + ) { + await this.testSubjects.click('dashboardSaveMenuItem'); + const modalDialog = await this.testSubjects.find('savedObjectSaveModal'); - await this.clickSave(); - if (saveOptions.waitDialogIsClosed) { - await testSubjects.waitForDeleted(modalDialog); - } + this.log.debug('entering new title'); + await this.testSubjects.setValue('savedObjectTitle', dashboardTitle); + + if (saveOptions.storeTimeWithDashboard !== undefined) { + await this.setStoreTimeWithDashboard(saveOptions.storeTimeWithDashboard); } - public async ensureDuplicateTitleCallout() { - await testSubjects.existOrFail('titleDupicateWarnMsg'); + const saveAsNewCheckboxExists = await this.testSubjects.exists('saveAsNewCheckbox'); + if (saveAsNewCheckboxExists) { + await this.setSaveAsNewCheckBox(Boolean(saveOptions.saveAsNew)); } - public async selectDashboardTags(tagNames: string[]) { - await testSubjects.click('savedObjectTagSelector'); - for (const tagName of tagNames) { - await testSubjects.click(`tagSelectorOption-${tagName.replace(' ', '_')}`); - } - await testSubjects.click('savedObjectTitle'); + if (saveOptions.tags) { + await this.selectDashboardTags(saveOptions.tags); } - /** - * @param dashboardTitle {String} - */ - public async enterDashboardTitleAndPressEnter(dashboardTitle: string) { - await testSubjects.click('dashboardSaveMenuItem'); - const modalDialog = await testSubjects.find('savedObjectSaveModal'); + await this.clickSave(); + if (saveOptions.waitDialogIsClosed) { + await this.testSubjects.waitForDeleted(modalDialog); + } + } - log.debug('entering new title'); - await testSubjects.setValue('savedObjectTitle', dashboardTitle); + public async ensureDuplicateTitleCallout() { + await this.testSubjects.existOrFail('titleDupicateWarnMsg'); + } - await PageObjects.common.pressEnterKey(); - await testSubjects.waitForDeleted(modalDialog); + public async selectDashboardTags(tagNames: string[]) { + await this.testSubjects.click('savedObjectTagSelector'); + for (const tagName of tagNames) { + await this.testSubjects.click(`tagSelectorOption-${tagName.replace(' ', '_')}`); } + await this.testSubjects.click('savedObjectTitle'); + } - // use the search filter box to narrow the results down to a single - // entry, or at least to a single page of results - public async loadSavedDashboard(dashboardName: string) { - log.debug(`Load Saved Dashboard ${dashboardName}`); + /** + * @param dashboardTitle {String} + */ + public async enterDashboardTitleAndPressEnter(dashboardTitle: string) { + await this.testSubjects.click('dashboardSaveMenuItem'); + const modalDialog = await this.testSubjects.find('savedObjectSaveModal'); - await this.gotoDashboardLandingPage(); + this.log.debug('entering new title'); + await this.testSubjects.setValue('savedObjectTitle', dashboardTitle); - await listingTable.searchForItemWithName(dashboardName); - await retry.try(async () => { - await listingTable.clickItemLink('dashboard', dashboardName); - await PageObjects.header.waitUntilLoadingHasFinished(); - // check Dashboard landing page is not present - await testSubjects.missingOrFail('dashboardLandingPage', { timeout: 10000 }); - }); - } + await this.common.pressEnterKey(); + await this.testSubjects.waitForDeleted(modalDialog); + } - public async getPanelTitles() { - log.debug('in getPanelTitles'); - const titleObjects = await testSubjects.findAll('dashboardPanelTitle'); - return await Promise.all(titleObjects.map(async (title) => await title.getVisibleText())); - } + // use the search filter box to narrow the results down to a single + // entry, or at least to a single page of results + public async loadSavedDashboard(dashboardName: string) { + this.log.debug(`Load Saved Dashboard ${dashboardName}`); - public async getPanelDimensions() { - const panels = await find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes - return await Promise.all( - panels.map(async (panel) => { - const size = await panel.getSize(); - return { - width: size.width, - height: size.height, - }; - }) - ); - } + await this.gotoDashboardLandingPage(); - public async getPanelCount() { - log.debug('getPanelCount'); - const panels = await testSubjects.findAll('embeddablePanel'); - return panels.length; - } + await this.listingTable.searchForItemWithName(dashboardName); + await this.retry.try(async () => { + await this.listingTable.clickItemLink('dashboard', dashboardName); + await this.header.waitUntilLoadingHasFinished(); + // check Dashboard landing page is not present + await this.testSubjects.missingOrFail('dashboardLandingPage', { timeout: 10000 }); + }); + } - public getTestVisualizations() { - return [ - { name: PIE_CHART_VIS_NAME, description: 'PieChart' }, - { name: 'Visualization☺ VerticalBarChart', description: 'VerticalBarChart' }, - { name: AREA_CHART_VIS_NAME, description: 'AreaChart' }, - { name: 'Visualization☺漢字 DataTable', description: 'DataTable' }, - { name: LINE_CHART_VIS_NAME, description: 'LineChart' }, - { name: 'Visualization TileMap', description: 'TileMap' }, - { name: 'Visualization MetricChart', description: 'MetricChart' }, - ]; - } + public async getPanelTitles() { + this.log.debug('in getPanelTitles'); + const titleObjects = await this.testSubjects.findAll('dashboardPanelTitle'); + return await Promise.all(titleObjects.map(async (title) => await title.getVisibleText())); + } - public getTestVisualizationNames() { - return this.getTestVisualizations().map((visualization) => visualization.name); - } + public async getPanelDimensions() { + const panels = await this.find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes + return await Promise.all( + panels.map(async (panel) => { + const size = await panel.getSize(); + return { + width: size.width, + height: size.height, + }; + }) + ); + } - public getTestVisualizationDescriptions() { - return this.getTestVisualizations().map((visualization) => visualization.description); - } + public async getPanelCount() { + this.log.debug('getPanelCount'); + const panels = await this.testSubjects.findAll('embeddablePanel'); + return panels.length; + } - public async getDashboardPanels() { - return await testSubjects.findAll('embeddablePanel'); - } + public getTestVisualizations() { + return [ + { name: PIE_CHART_VIS_NAME, description: 'PieChart' }, + { name: 'Visualization☺ VerticalBarChart', description: 'VerticalBarChart' }, + { name: AREA_CHART_VIS_NAME, description: 'AreaChart' }, + { name: 'Visualization☺漢字 DataTable', description: 'DataTable' }, + { name: LINE_CHART_VIS_NAME, description: 'LineChart' }, + { name: 'Visualization TileMap', description: 'TileMap' }, + { name: 'Visualization MetricChart', description: 'MetricChart' }, + ]; + } - public async addVisualizations(visualizations: string[]) { - await dashboardAddPanel.addVisualizations(visualizations); - } + public getTestVisualizationNames() { + return this.getTestVisualizations().map((visualization) => visualization.name); + } - public async setSaveAsNewCheckBox(checked: boolean) { - log.debug('saveAsNewCheckbox: ' + checked); - let saveAsNewCheckbox = await testSubjects.find('saveAsNewCheckbox'); - const isAlreadyChecked = (await saveAsNewCheckbox.getAttribute('aria-checked')) === 'true'; - if (isAlreadyChecked !== checked) { - log.debug('Flipping save as new checkbox'); - saveAsNewCheckbox = await testSubjects.find('saveAsNewCheckbox'); - await retry.try(() => saveAsNewCheckbox.click()); - } - } + public getTestVisualizationDescriptions() { + return this.getTestVisualizations().map((visualization) => visualization.description); + } - public async setStoreTimeWithDashboard(checked: boolean) { - log.debug('Storing time with dashboard: ' + checked); - let storeTimeCheckbox = await testSubjects.find('storeTimeWithDashboard'); - const isAlreadyChecked = (await storeTimeCheckbox.getAttribute('aria-checked')) === 'true'; - if (isAlreadyChecked !== checked) { - log.debug('Flipping store time checkbox'); - storeTimeCheckbox = await testSubjects.find('storeTimeWithDashboard'); - await retry.try(() => storeTimeCheckbox.click()); - } - } + public async getDashboardPanels() { + return await this.testSubjects.findAll('embeddablePanel'); + } - public async getSharedItemsCount() { - log.debug('in getSharedItemsCount'); - const attributeName = 'data-shared-items-count'; - const element = await find.byCssSelector(`[${attributeName}]`); - if (element) { - return await element.getAttribute(attributeName); - } + public async addVisualizations(visualizations: string[]) { + await this.dashboardAddPanel.addVisualizations(visualizations); + } - throw new Error('no element'); + public async setSaveAsNewCheckBox(checked: boolean) { + this.log.debug('saveAsNewCheckbox: ' + checked); + let saveAsNewCheckbox = await this.testSubjects.find('saveAsNewCheckbox'); + const isAlreadyChecked = (await saveAsNewCheckbox.getAttribute('aria-checked')) === 'true'; + if (isAlreadyChecked !== checked) { + this.log.debug('Flipping save as new checkbox'); + saveAsNewCheckbox = await this.testSubjects.find('saveAsNewCheckbox'); + await this.retry.try(() => saveAsNewCheckbox.click()); } + } - public async waitForRenderComplete() { - log.debug('waitForRenderComplete'); - const count = await this.getSharedItemsCount(); - // eslint-disable-next-line radix - await renderable.waitForRender(parseInt(count)); + public async setStoreTimeWithDashboard(checked: boolean) { + this.log.debug('Storing time with dashboard: ' + checked); + let storeTimeCheckbox = await this.testSubjects.find('storeTimeWithDashboard'); + const isAlreadyChecked = (await storeTimeCheckbox.getAttribute('aria-checked')) === 'true'; + if (isAlreadyChecked !== checked) { + this.log.debug('Flipping store time checkbox'); + storeTimeCheckbox = await this.testSubjects.find('storeTimeWithDashboard'); + await this.retry.try(() => storeTimeCheckbox.click()); } + } - public async getSharedContainerData() { - log.debug('getSharedContainerData'); - const sharedContainer = await find.byCssSelector('[data-shared-items-container]'); - return { - title: await sharedContainer.getAttribute('data-title'), - description: await sharedContainer.getAttribute('data-description'), - count: await sharedContainer.getAttribute('data-shared-items-count'), - }; + public async getSharedItemsCount() { + this.log.debug('in getSharedItemsCount'); + const attributeName = 'data-shared-items-count'; + const element = await this.find.byCssSelector(`[${attributeName}]`); + if (element) { + return await element.getAttribute(attributeName); } - public async getPanelSharedItemData() { - log.debug('in getPanelSharedItemData'); - const sharedItemscontainer = await find.byCssSelector('[data-shared-items-count]'); - const $ = await sharedItemscontainer.parseDomContent(); - return $('[data-shared-item]') - .toArray() - .map((item) => { - return { - title: $(item).attr('data-title'), - description: $(item).attr('data-description'), - }; - }); - } + throw new Error('no element'); + } - public async checkHideTitle() { - log.debug('ensure that you can click on hide title checkbox'); - await this.openOptions(); - return await testSubjects.click('dashboardPanelTitlesCheckbox'); - } + public async waitForRenderComplete() { + this.log.debug('waitForRenderComplete'); + const count = await this.getSharedItemsCount(); + // eslint-disable-next-line radix + await this.renderable.waitForRender(parseInt(count)); + } - public async expectMissingSaveOption() { - await testSubjects.missingOrFail('dashboardSaveMenuItem'); - } + public async getSharedContainerData() { + this.log.debug('getSharedContainerData'); + const sharedContainer = await this.find.byCssSelector('[data-shared-items-container]'); + return { + title: await sharedContainer.getAttribute('data-title'), + description: await sharedContainer.getAttribute('data-description'), + count: await sharedContainer.getAttribute('data-shared-items-count'), + }; + } - public async expectMissingQuickSaveOption() { - await testSubjects.missingOrFail('dashboardQuickSaveMenuItem'); - } - public async expectExistsQuickSaveOption() { - await testSubjects.existOrFail('dashboardQuickSaveMenuItem'); - } + public async getPanelSharedItemData() { + this.log.debug('in getPanelSharedItemData'); + const sharedItemscontainer = await this.find.byCssSelector('[data-shared-items-count]'); + const $ = await sharedItemscontainer.parseDomContent(); + return $('[data-shared-item]') + .toArray() + .map((item) => { + return { + title: $(item).attr('data-title'), + description: $(item).attr('data-description'), + }; + }); + } - public async expectQuickSaveButtonEnabled() { - log.debug('expectQuickSaveButtonEnabled'); - const quickSaveButton = await testSubjects.find('dashboardQuickSaveMenuItem'); - const isDisabled = await quickSaveButton.getAttribute('disabled'); - if (isDisabled) { - throw new Error('Quick save button disabled'); - } - } + public async checkHideTitle() { + this.log.debug('ensure that you can click on hide title checkbox'); + await this.openOptions(); + return await this.testSubjects.click('dashboardPanelTitlesCheckbox'); + } - public async getNotLoadedVisualizations(vizList: string[]) { - const checkList = []; - for (const name of vizList) { - const isPresent = await testSubjects.exists( - `embeddablePanelHeading-${name.replace(/\s+/g, '')}`, - { timeout: 10000 } - ); - checkList.push({ name, isPresent }); - } + public async expectMissingSaveOption() { + await this.testSubjects.missingOrFail('dashboardSaveMenuItem'); + } + + public async expectMissingQuickSaveOption() { + await this.testSubjects.missingOrFail('dashboardQuickSaveMenuItem'); + } + public async expectExistsQuickSaveOption() { + await this.testSubjects.existOrFail('dashboardQuickSaveMenuItem'); + } - return checkList.filter((viz) => viz.isPresent === false).map((viz) => viz.name); + public async expectQuickSaveButtonEnabled() { + this.log.debug('expectQuickSaveButtonEnabled'); + const quickSaveButton = await this.testSubjects.find('dashboardQuickSaveMenuItem'); + const isDisabled = await quickSaveButton.getAttribute('disabled'); + if (isDisabled) { + throw new Error('Quick save button disabled'); } + } - public async getPanelDrilldownCount(panelIndex = 0): Promise { - log.debug('getPanelDrilldownCount'); - const panel = (await this.getDashboardPanels())[panelIndex]; - try { - const count = await panel.findByTestSubject( - 'embeddablePanelNotification-ACTION_PANEL_NOTIFICATIONS' - ); - return Number.parseInt(await count.getVisibleText(), 10); - } catch (e) { - // if not found then this is 0 (we don't show badge with 0) - return 0; - } + public async getNotLoadedVisualizations(vizList: string[]) { + const checkList = []; + for (const name of vizList) { + const isPresent = await this.testSubjects.exists( + `embeddablePanelHeading-${name.replace(/\s+/g, '')}`, + { timeout: 10000 } + ); + checkList.push({ name, isPresent }); } - public async getPanelChartDebugState(panelIndex: number) { - return await elasticChart.getChartDebugData(undefined, panelIndex); + return checkList.filter((viz) => viz.isPresent === false).map((viz) => viz.name); + } + + public async getPanelDrilldownCount(panelIndex = 0): Promise { + this.log.debug('getPanelDrilldownCount'); + const panel = (await this.getDashboardPanels())[panelIndex]; + try { + const count = await panel.findByTestSubject( + 'embeddablePanelNotification-ACTION_PANEL_NOTIFICATIONS' + ); + return Number.parseInt(await count.getVisibleText(), 10); + } catch (e) { + // if not found then this is 0 (we don't show badge with 0) + return 0; } } - return new DashboardPage(); + public async getPanelChartDebugState(panelIndex: number) { + return await this.elasticChart.getChartDebugData(undefined, panelIndex); + } } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 436d22d659aec6..41c4441a1c95de 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -6,511 +6,510 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; - -export function DiscoverPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const find = getService('find'); - const flyout = getService('flyout'); - const { header } = getPageObjects(['header']); - const browser = getService('browser'); - const globalNav = getService('globalNav'); - const elasticChart = getService('elasticChart'); - const docTable = getService('docTable'); - const config = getService('config'); - const defaultFindTimeout = config.get('timeouts.find'); - const dataGrid = getService('dataGrid'); - const kibanaServer = getService('kibanaServer'); - - class DiscoverPage { - public async getChartTimespan() { - const el = await find.byCssSelector('[data-test-subj="discoverIntervalDateRange"]'); - return await el.getVisibleText(); - } +import { FtrService } from '../ftr_provider_context'; + +export class DiscoverPageObject extends FtrService { + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly find = this.ctx.getService('find'); + private readonly flyout = this.ctx.getService('flyout'); + private readonly header = this.ctx.getPageObject('header'); + private readonly browser = this.ctx.getService('browser'); + private readonly globalNav = this.ctx.getService('globalNav'); + private readonly elasticChart = this.ctx.getService('elasticChart'); + private readonly docTable = this.ctx.getService('docTable'); + private readonly config = this.ctx.getService('config'); + private readonly dataGrid = this.ctx.getService('dataGrid'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); + + private readonly defaultFindTimeout = this.config.get('timeouts.find'); + + public async getChartTimespan() { + const el = await this.find.byCssSelector('[data-test-subj="discoverIntervalDateRange"]'); + return await el.getVisibleText(); + } - public async getDocTable() { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - return docTable; - } else { - return dataGrid; - } + public async getDocTable() { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + return this.docTable; + } else { + return this.dataGrid; } + } - public async findFieldByName(name: string) { - const fieldSearch = await testSubjects.find('fieldFilterSearchInput'); - await fieldSearch.type(name); - } + public async findFieldByName(name: string) { + const fieldSearch = await this.testSubjects.find('fieldFilterSearchInput'); + await fieldSearch.type(name); + } - public async clearFieldSearchInput() { - const fieldSearch = await testSubjects.find('fieldFilterSearchInput'); - await fieldSearch.clearValue(); - } + public async clearFieldSearchInput() { + const fieldSearch = await this.testSubjects.find('fieldFilterSearchInput'); + await fieldSearch.clearValue(); + } - public async saveSearch(searchName: string) { - await this.clickSaveSearchButton(); - // preventing an occasional flakiness when the saved object wasn't set and the form can't be submitted - await retry.waitFor( - `saved search title is set to ${searchName} and save button is clickable`, - async () => { - const saveButton = await testSubjects.find('confirmSaveSavedObjectButton'); - await testSubjects.setValue('savedObjectTitle', searchName); - return (await saveButton.getAttribute('disabled')) !== 'true'; - } - ); - await testSubjects.click('confirmSaveSavedObjectButton'); - await header.waitUntilLoadingHasFinished(); - // LeeDr - this additional checking for the saved search name was an attempt - // to cause this method to wait for the reloading of the page to complete so - // that the next action wouldn't have to retry. But it doesn't really solve - // that issue. But it does typically take about 3 retries to - // complete with the expected searchName. - await retry.waitFor(`saved search was persisted with name ${searchName}`, async () => { - return (await this.getCurrentQueryName()) === searchName; - }); - } + public async saveSearch(searchName: string) { + await this.clickSaveSearchButton(); + // preventing an occasional flakiness when the saved object wasn't set and the form can't be submitted + await this.retry.waitFor( + `saved search title is set to ${searchName} and save button is clickable`, + async () => { + const saveButton = await this.testSubjects.find('confirmSaveSavedObjectButton'); + await this.testSubjects.setValue('savedObjectTitle', searchName); + return (await saveButton.getAttribute('disabled')) !== 'true'; + } + ); + await this.testSubjects.click('confirmSaveSavedObjectButton'); + await this.header.waitUntilLoadingHasFinished(); + // LeeDr - this additional checking for the saved search name was an attempt + // to cause this method to wait for the reloading of the page to complete so + // that the next action wouldn't have to retry. But it doesn't really solve + // that issue. But it does typically take about 3 retries to + // complete with the expected searchName. + await this.retry.waitFor(`saved search was persisted with name ${searchName}`, async () => { + return (await this.getCurrentQueryName()) === searchName; + }); + } - public async inputSavedSearchTitle(searchName: string) { - await testSubjects.setValue('savedObjectTitle', searchName); - } + public async inputSavedSearchTitle(searchName: string) { + await this.testSubjects.setValue('savedObjectTitle', searchName); + } - public async clickConfirmSavedSearch() { - await testSubjects.click('confirmSaveSavedObjectButton'); - } + public async clickConfirmSavedSearch() { + await this.testSubjects.click('confirmSaveSavedObjectButton'); + } - public async openAddFilterPanel() { - await testSubjects.click('addFilter'); - } + public async openAddFilterPanel() { + await this.testSubjects.click('addFilter'); + } - public async waitUntilSearchingHasFinished() { - await testSubjects.missingOrFail('loadingSpinner', { timeout: defaultFindTimeout * 10 }); + public async waitUntilSearchingHasFinished() { + await this.testSubjects.missingOrFail('loadingSpinner', { + timeout: this.defaultFindTimeout * 10, + }); + } + + public async getColumnHeaders() { + const isLegacy = await this.useLegacyTable(); + if (isLegacy) { + return await this.docTable.getHeaderFields('embeddedSavedSearchDocTable'); } + const table = await this.getDocTable(); + return await table.getHeaderFields(); + } - public async getColumnHeaders() { - const isLegacy = await this.useLegacyTable(); - if (isLegacy) { - return await docTable.getHeaderFields('embeddedSavedSearchDocTable'); - } - const table = await this.getDocTable(); - return await table.getHeaderFields(); + public async openLoadSavedSearchPanel() { + let isOpen = await this.testSubjects.exists('loadSearchForm'); + if (isOpen) { + return; } - public async openLoadSavedSearchPanel() { - let isOpen = await testSubjects.exists('loadSearchForm'); - if (isOpen) { - return; - } + // We need this try loop here because previous actions in Discover like + // saving a search cause reloading of the page and the "Open" menu item goes stale. + await this.retry.waitFor('saved search panel is opened', async () => { + await this.clickLoadSavedSearchButton(); + await this.header.waitUntilLoadingHasFinished(); + isOpen = await this.testSubjects.exists('loadSearchForm'); + return isOpen === true; + }); + } - // We need this try loop here because previous actions in Discover like - // saving a search cause reloading of the page and the "Open" menu item goes stale. - await retry.waitFor('saved search panel is opened', async () => { - await this.clickLoadSavedSearchButton(); - await header.waitUntilLoadingHasFinished(); - isOpen = await testSubjects.exists('loadSearchForm'); - return isOpen === true; - }); - } + public async closeLoadSaveSearchPanel() { + await this.flyout.ensureClosed('loadSearchForm'); + } - public async closeLoadSaveSearchPanel() { - await flyout.ensureClosed('loadSearchForm'); - } + public async hasSavedSearch(searchName: string) { + const searchLink = await this.find.byButtonText(searchName); + return await searchLink.isDisplayed(); + } - public async hasSavedSearch(searchName: string) { - const searchLink = await find.byButtonText(searchName); - return await searchLink.isDisplayed(); - } + public async loadSavedSearch(searchName: string) { + await this.openLoadSavedSearchPanel(); + await this.testSubjects.click(`savedObjectTitle${searchName.split(' ').join('-')}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async loadSavedSearch(searchName: string) { - await this.openLoadSavedSearchPanel(); - await testSubjects.click(`savedObjectTitle${searchName.split(' ').join('-')}`); - await header.waitUntilLoadingHasFinished(); - } + public async clickNewSearchButton() { + await this.testSubjects.click('discoverNewButton'); + await this.header.waitUntilLoadingHasFinished(); + } - public async clickNewSearchButton() { - await testSubjects.click('discoverNewButton'); - await header.waitUntilLoadingHasFinished(); - } + public async clickSaveSearchButton() { + await this.testSubjects.click('discoverSaveButton'); + } - public async clickSaveSearchButton() { - await testSubjects.click('discoverSaveButton'); - } + public async clickLoadSavedSearchButton() { + await this.testSubjects.moveMouseTo('discoverOpenButton'); + await this.testSubjects.click('discoverOpenButton'); + } - public async clickLoadSavedSearchButton() { - await testSubjects.moveMouseTo('discoverOpenButton'); - await testSubjects.click('discoverOpenButton'); - } + public async clickResetSavedSearchButton() { + await this.testSubjects.moveMouseTo('resetSavedSearch'); + await this.testSubjects.click('resetSavedSearch'); + await this.header.waitUntilLoadingHasFinished(); + } - public async clickResetSavedSearchButton() { - await testSubjects.moveMouseTo('resetSavedSearch'); - await testSubjects.click('resetSavedSearch'); - await header.waitUntilLoadingHasFinished(); - } + public async closeLoadSavedSearchPanel() { + await this.testSubjects.click('euiFlyoutCloseButton'); + } - public async closeLoadSavedSearchPanel() { - await testSubjects.click('euiFlyoutCloseButton'); - } + public async clickHistogramBar() { + await this.elasticChart.waitForRenderComplete(); + const el = await this.elasticChart.getCanvas(); - public async clickHistogramBar() { - await elasticChart.waitForRenderComplete(); - const el = await elasticChart.getCanvas(); + await this.browser.getActions().move({ x: 0, y: 0, origin: el._webElement }).click().perform(); + } - await browser.getActions().move({ x: 0, y: 0, origin: el._webElement }).click().perform(); - } + public async brushHistogram() { + await this.elasticChart.waitForRenderComplete(); + const el = await this.elasticChart.getCanvas(); - public async brushHistogram() { - await elasticChart.waitForRenderComplete(); - const el = await elasticChart.getCanvas(); + await this.browser.dragAndDrop( + { location: el, offset: { x: -300, y: 20 } }, + { location: el, offset: { x: -100, y: 30 } } + ); + } - await browser.dragAndDrop( - { location: el, offset: { x: -300, y: 20 } }, - { location: el, offset: { x: -100, y: 30 } } - ); - } + public async getCurrentQueryName() { + return await this.globalNav.getLastBreadcrumb(); + } - public async getCurrentQueryName() { - return await globalNav.getLastBreadcrumb(); - } + public async getChartInterval() { + const selectedValue = await this.testSubjects.getAttribute('discoverIntervalSelect', 'value'); + const selectedOption = await this.find.byCssSelector(`option[value="${selectedValue}"]`); + return selectedOption.getVisibleText(); + } - public async getChartInterval() { - const selectedValue = await testSubjects.getAttribute('discoverIntervalSelect', 'value'); - const selectedOption = await find.byCssSelector(`option[value="${selectedValue}"]`); - return selectedOption.getVisibleText(); - } + public async getChartIntervalWarningIcon() { + await this.header.waitUntilLoadingHasFinished(); + return await this.find.existsByCssSelector('.euiToolTipAnchor'); + } - public async getChartIntervalWarningIcon() { - await header.waitUntilLoadingHasFinished(); - return await find.existsByCssSelector('.euiToolTipAnchor'); - } + public async setChartInterval(interval: string) { + const optionElement = await this.find.byCssSelector(`option[label="${interval}"]`, 5000); + await optionElement.click(); + return await this.header.waitUntilLoadingHasFinished(); + } - public async setChartInterval(interval: string) { - const optionElement = await find.byCssSelector(`option[label="${interval}"]`, 5000); - await optionElement.click(); - return await header.waitUntilLoadingHasFinished(); - } + public async getHitCount() { + await this.header.waitUntilLoadingHasFinished(); + return await this.testSubjects.getVisibleText('discoverQueryHits'); + } - public async getHitCount() { - await header.waitUntilLoadingHasFinished(); - return await testSubjects.getVisibleText('discoverQueryHits'); - } + public async getDocHeader() { + const table = await this.getDocTable(); + const docHeader = await table.getHeaders(); + return docHeader.join(); + } - public async getDocHeader() { - const table = await this.getDocTable(); - const docHeader = await table.getHeaders(); - return docHeader.join(); - } + public async getDocTableRows() { + await this.header.waitUntilLoadingHasFinished(); + const table = await this.getDocTable(); + return await table.getBodyRows(); + } - public async getDocTableRows() { - await header.waitUntilLoadingHasFinished(); - const table = await this.getDocTable(); - return await table.getBodyRows(); - } + public async useLegacyTable() { + return (await this.kibanaServer.uiSettings.get('doc_table:legacy')) !== false; + } - public async useLegacyTable() { - return (await kibanaServer.uiSettings.get('doc_table:legacy')) !== false; + public async getDocTableIndex(index: number) { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + const row = await this.find.byCssSelector(`tr.kbnDocTable__row:nth-child(${index})`); + return await row.getVisibleText(); } - public async getDocTableIndex(index: number) { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - const row = await find.byCssSelector(`tr.kbnDocTable__row:nth-child(${index})`); - return await row.getVisibleText(); - } + const row = await this.dataGrid.getRow({ rowIndex: index - 1 }); + const result = await Promise.all(row.map(async (cell) => await cell.getVisibleText())); + // Remove control columns + return result.slice(2).join(' '); + } - const row = await dataGrid.getRow({ rowIndex: index - 1 }); - const result = await Promise.all(row.map(async (cell) => await cell.getVisibleText())); - // Remove control columns - return result.slice(2).join(' '); - } + public async getDocTableIndexLegacy(index: number) { + const row = await this.find.byCssSelector(`tr.kbnDocTable__row:nth-child(${index})`); + return await row.getVisibleText(); + } - public async getDocTableIndexLegacy(index: number) { - const row = await find.byCssSelector(`tr.kbnDocTable__row:nth-child(${index})`); - return await row.getVisibleText(); + public async getDocTableField(index: number, cellIdx: number = -1) { + const isLegacyDefault = await this.useLegacyTable(); + const usedDefaultCellIdx = isLegacyDefault ? 0 : 2; + const usedCellIdx = cellIdx === -1 ? usedDefaultCellIdx : cellIdx; + if (isLegacyDefault) { + const fields = await this.find.allByCssSelector( + `tr.kbnDocTable__row:nth-child(${index}) [data-test-subj='docTableField']` + ); + return await fields[usedCellIdx].getVisibleText(); } + const row = await this.dataGrid.getRow({ rowIndex: index - 1 }); + const result = await Promise.all(row.map(async (cell) => await cell.getVisibleText())); + return result[usedCellIdx]; + } - public async getDocTableField(index: number, cellIdx: number = -1) { - const isLegacyDefault = await this.useLegacyTable(); - const usedDefaultCellIdx = isLegacyDefault ? 0 : 2; - const usedCellIdx = cellIdx === -1 ? usedDefaultCellIdx : cellIdx; - if (isLegacyDefault) { - const fields = await find.allByCssSelector( - `tr.kbnDocTable__row:nth-child(${index}) [data-test-subj='docTableField']` - ); - return await fields[usedCellIdx].getVisibleText(); - } - const row = await dataGrid.getRow({ rowIndex: index - 1 }); - const result = await Promise.all(row.map(async (cell) => await cell.getVisibleText())); - return result[usedCellIdx]; - } + public async skipToEndOfDocTable() { + // add the focus to the button to make it appear + const skipButton = await this.testSubjects.find('discoverSkipTableButton'); + // force focus on it, to make it interactable + skipButton.focus(); + // now click it! + return skipButton.click(); + } - public async skipToEndOfDocTable() { - // add the focus to the button to make it appear - const skipButton = await testSubjects.find('discoverSkipTableButton'); - // force focus on it, to make it interactable - skipButton.focus(); - // now click it! - return skipButton.click(); - } + /** + * When scrolling down the legacy table there's a link to scroll up + * So this is done by this function + */ + public async backToTop() { + const skipButton = await this.testSubjects.find('discoverBackToTop'); + return skipButton.click(); + } - /** - * When scrolling down the legacy table there's a link to scroll up - * So this is done by this function - */ - public async backToTop() { - const skipButton = await testSubjects.find('discoverBackToTop'); - return skipButton.click(); - } + public async getDocTableFooter() { + return await this.testSubjects.find('discoverDocTableFooter'); + } - public async getDocTableFooter() { - return await testSubjects.find('discoverDocTableFooter'); + public async clickDocSortDown() { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + await this.find.clickByCssSelector('.fa-sort-down'); + } else { + await this.dataGrid.clickDocSortAsc(); } + } - public async clickDocSortDown() { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - await find.clickByCssSelector('.fa-sort-down'); - } else { - await dataGrid.clickDocSortAsc(); - } + public async clickDocSortUp() { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + await this.find.clickByCssSelector('.fa-sort-up'); + } else { + await this.dataGrid.clickDocSortDesc(); } + } - public async clickDocSortUp() { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - await find.clickByCssSelector('.fa-sort-up'); - } else { - await dataGrid.clickDocSortDesc(); - } - } + public async isShowingDocViewer() { + return await this.testSubjects.exists('kbnDocViewer'); + } - public async isShowingDocViewer() { - return await testSubjects.exists('kbnDocViewer'); - } + public async getMarks() { + const table = await this.docTable.getTable(); + const marks = await table.findAllByTagName('mark'); + return await Promise.all(marks.map((mark) => mark.getVisibleText())); + } - public async getMarks() { - const table = await docTable.getTable(); - const marks = await table.findAllByTagName('mark'); - return await Promise.all(marks.map((mark) => mark.getVisibleText())); - } + public async toggleSidebarCollapse() { + return await this.testSubjects.click('collapseSideBarButton'); + } - public async toggleSidebarCollapse() { - return await testSubjects.click('collapseSideBarButton'); - } + public async getAllFieldNames() { + const sidebar = await this.testSubjects.find('discover-sidebar'); + const $ = await sidebar.parseDomContent(); + return $('.dscSidebarField__name') + .toArray() + .map((field) => $(field).text()); + } - public async getAllFieldNames() { - const sidebar = await testSubjects.find('discover-sidebar'); - const $ = await sidebar.parseDomContent(); - return $('.dscSidebarField__name') - .toArray() - .map((field) => $(field).text()); - } + public async editField(field: string) { + await this.retry.try(async () => { + await this.testSubjects.click(`field-${field}`); + await this.testSubjects.click(`discoverFieldListPanelEdit-${field}`); + await this.find.byClassName('indexPatternFieldEditor__form'); + }); + } - public async editField(field: string) { - await retry.try(async () => { - await testSubjects.click(`field-${field}`); - await testSubjects.click(`discoverFieldListPanelEdit-${field}`); - await find.byClassName('indexPatternFieldEditor__form'); - }); - } + public async removeField(field: string) { + await this.testSubjects.click(`field-${field}`); + await this.testSubjects.click(`discoverFieldListPanelDelete-${field}`); + await this.testSubjects.existOrFail('runtimeFieldDeleteConfirmModal'); + } - public async removeField(field: string) { - await testSubjects.click(`field-${field}`); - await testSubjects.click(`discoverFieldListPanelDelete-${field}`); - await testSubjects.existOrFail('runtimeFieldDeleteConfirmModal'); - } + public async clickIndexPatternActions() { + await this.retry.try(async () => { + await this.testSubjects.click('discoverIndexPatternActions'); + await this.testSubjects.existOrFail('discover-addRuntimeField-popover'); + }); + } - public async clickIndexPatternActions() { - await retry.try(async () => { - await testSubjects.click('discoverIndexPatternActions'); - await testSubjects.existOrFail('discover-addRuntimeField-popover'); - }); - } + public async clickAddNewField() { + await this.retry.try(async () => { + await this.testSubjects.click('indexPattern-add-field'); + await this.find.byClassName('indexPatternFieldEditor__form'); + }); + } - public async clickAddNewField() { - await retry.try(async () => { - await testSubjects.click('indexPattern-add-field'); - await find.byClassName('indexPatternFieldEditor__form'); - }); - } + public async hasNoResults() { + return await this.testSubjects.exists('discoverNoResults'); + } - public async hasNoResults() { - return await testSubjects.exists('discoverNoResults'); - } + public async hasNoResultsTimepicker() { + return await this.testSubjects.exists('discoverNoResultsTimefilter'); + } - public async hasNoResultsTimepicker() { - return await testSubjects.exists('discoverNoResultsTimefilter'); - } + public async clickFieldListItem(field: string) { + return await this.testSubjects.click(`field-${field}`); + } - public async clickFieldListItem(field: string) { - return await testSubjects.click(`field-${field}`); + public async clickFieldSort(field: string, text = 'Sort New-Old') { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + return await this.testSubjects.click(`docTableHeaderFieldSort_${field}`); } + return await this.dataGrid.clickDocSortAsc(field, text); + } - public async clickFieldSort(field: string, text = 'Sort New-Old') { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - return await testSubjects.click(`docTableHeaderFieldSort_${field}`); - } - return await dataGrid.clickDocSortAsc(field, text); - } + public async clickFieldListItemToggle(field: string) { + await this.testSubjects.moveMouseTo(`field-${field}`); + await this.testSubjects.click(`fieldToggle-${field}`); + } - public async clickFieldListItemToggle(field: string) { - await testSubjects.moveMouseTo(`field-${field}`); - await testSubjects.click(`fieldToggle-${field}`); - } + public async clickFieldListItemAdd(field: string) { + // a filter check may make sense here, but it should be properly handled to make + // it work with the _score and _source fields as well + await this.clickFieldListItemToggle(field); + } - public async clickFieldListItemAdd(field: string) { - // a filter check may make sense here, but it should be properly handled to make - // it work with the _score and _source fields as well - await this.clickFieldListItemToggle(field); + public async clickFieldListItemRemove(field: string) { + if (!(await this.testSubjects.exists('fieldList-selected'))) { + return; } - - public async clickFieldListItemRemove(field: string) { - if (!(await testSubjects.exists('fieldList-selected'))) { - return; - } - const selectedList = await testSubjects.find('fieldList-selected'); - if (await testSubjects.descendantExists(`field-${field}`, selectedList)) { - await this.clickFieldListItemToggle(field); - } + const selectedList = await this.testSubjects.find('fieldList-selected'); + if (await this.testSubjects.descendantExists(`field-${field}`, selectedList)) { + await this.clickFieldListItemToggle(field); } + } - public async clickFieldListItemVisualize(fieldName: string) { - const field = await testSubjects.find(`field-${fieldName}-showDetails`); - const isActive = await field.elementHasClass('dscSidebarItem--active'); + public async clickFieldListItemVisualize(fieldName: string) { + const field = await this.testSubjects.find(`field-${fieldName}-showDetails`); + const isActive = await field.elementHasClass('dscSidebarItem--active'); - if (!isActive) { - // expand the field to show the "Visualize" button - await field.click(); - } - - await testSubjects.click(`fieldVisualize-${fieldName}`); + if (!isActive) { + // expand the field to show the "Visualize" button + await field.click(); } - public async expectFieldListItemVisualize(field: string) { - await testSubjects.existOrFail(`fieldVisualize-${field}`); - } + await this.testSubjects.click(`fieldVisualize-${fieldName}`); + } - public async expectMissingFieldListItemVisualize(field: string) { - await testSubjects.missingOrFail(`fieldVisualize-${field}`); - } + public async expectFieldListItemVisualize(field: string) { + await this.testSubjects.existOrFail(`fieldVisualize-${field}`); + } - public async clickFieldListPlusFilter(field: string, value: string) { - const plusFilterTestSubj = `plus-${field}-${value}`; - if (!(await testSubjects.exists(plusFilterTestSubj))) { - // field has to be open - await this.clickFieldListItem(field); - } - // testSubjects.find doesn't handle spaces in the data-test-subj value - await testSubjects.click(plusFilterTestSubj); - await header.waitUntilLoadingHasFinished(); - } + public async expectMissingFieldListItemVisualize(field: string) { + await this.testSubjects.missingOrFail(`fieldVisualize-${field}`); + } - public async clickFieldListMinusFilter(field: string, value: string) { - // this method requires the field details to be open from clickFieldListItem() - // testSubjects.find doesn't handle spaces in the data-test-subj value - await testSubjects.click(`minus-${field}-${value}`); - await header.waitUntilLoadingHasFinished(); + public async clickFieldListPlusFilter(field: string, value: string) { + const plusFilterTestSubj = `plus-${field}-${value}`; + if (!(await this.testSubjects.exists(plusFilterTestSubj))) { + // field has to be open + await this.clickFieldListItem(field); } + // this.testSubjects.find doesn't handle spaces in the data-test-subj value + await this.testSubjects.click(plusFilterTestSubj); + await this.header.waitUntilLoadingHasFinished(); + } - public async selectIndexPattern(indexPattern: string) { - await testSubjects.click('indexPattern-switch-link'); - await find.setValue('[data-test-subj="indexPattern-switcher"] input', indexPattern); - await find.clickByCssSelector( - `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` - ); - await header.waitUntilLoadingHasFinished(); - } + public async clickFieldListMinusFilter(field: string, value: string) { + // this method requires the field details to be open from clickFieldListItem() + // this.testSubjects.find doesn't handle spaces in the data-test-subj value + await this.testSubjects.click(`minus-${field}-${value}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async removeHeaderColumn(name: string) { - const isLegacyDefault = await this.useLegacyTable(); - if (isLegacyDefault) { - await testSubjects.moveMouseTo(`docTableHeader-${name}`); - await testSubjects.click(`docTableRemoveHeader-${name}`); - } else { - await dataGrid.clickRemoveColumn(name); - } - } + public async selectIndexPattern(indexPattern: string) { + await this.testSubjects.click('indexPattern-switch-link'); + await this.find.setValue('[data-test-subj="indexPattern-switcher"] input', indexPattern); + await this.find.clickByCssSelector( + `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` + ); + await this.header.waitUntilLoadingHasFinished(); + } - public async openSidebarFieldFilter() { - await testSubjects.click('toggleFieldFilterButton'); - await testSubjects.existOrFail('filterSelectionPanel'); + public async removeHeaderColumn(name: string) { + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + await this.testSubjects.moveMouseTo(`docTableHeader-${name}`); + await this.testSubjects.click(`docTableRemoveHeader-${name}`); + } else { + await this.dataGrid.clickRemoveColumn(name); } + } - public async closeSidebarFieldFilter() { - await testSubjects.click('toggleFieldFilterButton'); - await testSubjects.missingOrFail('filterSelectionPanel'); - } + public async openSidebarFieldFilter() { + await this.testSubjects.click('toggleFieldFilterButton'); + await this.testSubjects.existOrFail('filterSelectionPanel'); + } - public async waitForChartLoadingComplete(renderCount: number) { - await elasticChart.waitForRenderingCount(renderCount, 'discoverChart'); - } + public async closeSidebarFieldFilter() { + await this.testSubjects.click('toggleFieldFilterButton'); + await this.testSubjects.missingOrFail('filterSelectionPanel'); + } - public async waitForDocTableLoadingComplete() { - await testSubjects.waitForAttributeToChange( - 'discoverDocTable', - 'data-render-complete', - 'true' - ); - } - public async getNrOfFetches() { - const el = await find.byCssSelector('[data-fetch-counter]'); - const nr = await el.getAttribute('data-fetch-counter'); - return Number(nr); - } + public async waitForChartLoadingComplete(renderCount: number) { + await this.elasticChart.waitForRenderingCount(renderCount, 'discoverChart'); + } - /** - * Check if Discover app is currently rendered on the screen. - */ - public async isDiscoverAppOnScreen(): Promise { - const result = await find.allByCssSelector('discover-app'); - return result.length === 1; - } + public async waitForDocTableLoadingComplete() { + await this.testSubjects.waitForAttributeToChange( + 'discoverDocTable', + 'data-render-complete', + 'true' + ); + } + public async getNrOfFetches() { + const el = await this.find.byCssSelector('[data-fetch-counter]'); + const nr = await el.getAttribute('data-fetch-counter'); + return Number(nr); + } - /** - * Wait until Discover app is rendered on the screen. - */ - public async waitForDiscoverAppOnScreen() { - await retry.waitFor('Discover app on screen', async () => { - return await this.isDiscoverAppOnScreen(); - }); - } + /** + * Check if Discover app is currently rendered on the screen. + */ + public async isDiscoverAppOnScreen(): Promise { + const result = await this.find.allByCssSelector('discover-app'); + return result.length === 1; + } - public async showAllFilterActions() { - await testSubjects.click('showFilterActions'); - } + /** + * Wait until Discover app is rendered on the screen. + */ + public async waitForDiscoverAppOnScreen() { + await this.retry.waitFor('Discover app on screen', async () => { + return await this.isDiscoverAppOnScreen(); + }); + } - public async clickSavedQueriesPopOver() { - await testSubjects.click('saved-query-management-popover-button'); - } + public async showAllFilterActions() { + await this.testSubjects.click('showFilterActions'); + } - public async clickCurrentSavedQuery() { - await testSubjects.click('saved-query-management-save-button'); - } + public async clickSavedQueriesPopOver() { + await this.testSubjects.click('saved-query-management-popover-button'); + } - public async setSaveQueryFormTitle(savedQueryName: string) { - await testSubjects.setValue('saveQueryFormTitle', savedQueryName); - } + public async clickCurrentSavedQuery() { + await this.testSubjects.click('saved-query-management-save-button'); + } - public async toggleIncludeFilters() { - await testSubjects.click('saveQueryFormIncludeFiltersOption'); - } + public async setSaveQueryFormTitle(savedQueryName: string) { + await this.testSubjects.setValue('saveQueryFormTitle', savedQueryName); + } - public async saveCurrentSavedQuery() { - await testSubjects.click('savedQueryFormSaveButton'); - } + public async toggleIncludeFilters() { + await this.testSubjects.click('saveQueryFormIncludeFiltersOption'); + } - public async deleteSavedQuery() { - await testSubjects.click('delete-saved-query-TEST-button'); - } + public async saveCurrentSavedQuery() { + await this.testSubjects.click('savedQueryFormSaveButton'); + } - public async confirmDeletionOfSavedQuery() { - await testSubjects.click('confirmModalConfirmButton'); - } + public async deleteSavedQuery() { + await this.testSubjects.click('delete-saved-query-TEST-button'); + } - public async clearSavedQuery() { - await testSubjects.click('saved-query-management-clear-button'); - } + public async confirmDeletionOfSavedQuery() { + await this.testSubjects.click('confirmModalConfirmButton'); } - return new DiscoverPage(); + public async clearSavedQuery() { + await this.testSubjects.click('saved-query-management-clear-button'); + } } diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts index 99c17c632720a1..b0166e3753dd55 100644 --- a/test/functional/page_objects/error_page.ts +++ b/test/functional/page_objects/error_page.ts @@ -7,28 +7,24 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { - const { common } = getPageObjects(['common']); +export class ErrorPageObject extends FtrService { + private readonly common = this.ctx.getPageObject('common'); - class ErrorPage { - public async expectForbidden() { - const messageText = await common.getBodyText(); - expect(messageText).to.contain('You do not have permission to access the requested page'); - } - - public async expectNotFound() { - const messageText = await common.getJsonBodyText(); - expect(messageText).to.eql( - JSON.stringify({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }) - ); - } + public async expectForbidden() { + const messageText = await this.common.getBodyText(); + expect(messageText).to.contain('You do not have permission to access the requested page'); } - return new ErrorPage(); + public async expectNotFound() { + const messageText = await this.common.getJsonBodyText(); + expect(messageText).to.eql( + JSON.stringify({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }) + ); + } } diff --git a/test/functional/page_objects/header_page.ts b/test/functional/page_objects/header_page.ts index c5a796a1eb13be..8597a4b4ee2fbb 100644 --- a/test/functional/page_objects/header_page.ts +++ b/test/functional/page_objects/header_page.ts @@ -6,92 +6,88 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function HeaderPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const config = getService('config'); - const log = getService('log'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const appsMenu = getService('appsMenu'); - const PageObjects = getPageObjects(['common']); +export class HeaderPageObject extends FtrService { + private readonly config = this.ctx.getService('config'); + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly appsMenu = this.ctx.getService('appsMenu'); + private readonly common = this.ctx.getPageObject('common'); - const defaultFindTimeout = config.get('timeouts.find'); + private readonly defaultFindTimeout = this.config.get('timeouts.find'); - class HeaderPage { - public async clickDiscover(ignoreAppLeaveWarning = false) { - await appsMenu.clickLink('Discover', { category: 'kibana' }); - await this.onAppLeaveWarning(ignoreAppLeaveWarning); - await PageObjects.common.waitForTopNavToBeVisible(); - await this.awaitGlobalLoadingIndicatorHidden(); - } + public async clickDiscover(ignoreAppLeaveWarning = false) { + await this.appsMenu.clickLink('Discover', { category: 'kibana' }); + await this.onAppLeaveWarning(ignoreAppLeaveWarning); + await this.common.waitForTopNavToBeVisible(); + await this.awaitGlobalLoadingIndicatorHidden(); + } - public async clickVisualize(ignoreAppLeaveWarning = false) { - await appsMenu.clickLink('Visualize Library', { category: 'kibana' }); - await this.onAppLeaveWarning(ignoreAppLeaveWarning); - await this.awaitGlobalLoadingIndicatorHidden(); - await retry.waitFor('Visualize app to be loaded', async () => { - const isNavVisible = await testSubjects.exists('top-nav'); - return isNavVisible; - }); - } + public async clickVisualize(ignoreAppLeaveWarning = false) { + await this.appsMenu.clickLink('Visualize Library', { category: 'kibana' }); + await this.onAppLeaveWarning(ignoreAppLeaveWarning); + await this.awaitGlobalLoadingIndicatorHidden(); + await this.retry.waitFor('Visualize app to be loaded', async () => { + const isNavVisible = await this.testSubjects.exists('top-nav'); + return isNavVisible; + }); + } - public async clickDashboard() { - await appsMenu.clickLink('Dashboard', { category: 'kibana' }); - await retry.waitFor('dashboard app to be loaded', async () => { - const isNavVisible = await testSubjects.exists('top-nav'); - const isLandingPageVisible = await testSubjects.exists('dashboardLandingPage'); - return isNavVisible || isLandingPageVisible; - }); - await this.awaitGlobalLoadingIndicatorHidden(); - } + public async clickDashboard() { + await this.appsMenu.clickLink('Dashboard', { category: 'kibana' }); + await this.retry.waitFor('dashboard app to be loaded', async () => { + const isNavVisible = await this.testSubjects.exists('top-nav'); + const isLandingPageVisible = await this.testSubjects.exists('dashboardLandingPage'); + return isNavVisible || isLandingPageVisible; + }); + await this.awaitGlobalLoadingIndicatorHidden(); + } - public async clickStackManagement() { - await appsMenu.clickLink('Stack Management', { category: 'management' }); - await this.awaitGlobalLoadingIndicatorHidden(); - } + public async clickStackManagement() { + await this.appsMenu.clickLink('Stack Management', { category: 'management' }); + await this.awaitGlobalLoadingIndicatorHidden(); + } - public async waitUntilLoadingHasFinished() { - try { - await this.isGlobalLoadingIndicatorVisible(); - } catch (exception) { - if (exception.name === 'ElementNotVisible') { - // selenium might just have been too slow to catch it - } else { - throw exception; - } + public async waitUntilLoadingHasFinished() { + try { + await this.isGlobalLoadingIndicatorVisible(); + } catch (exception) { + if (exception.name === 'ElementNotVisible') { + // selenium might just have been too slow to catch it + } else { + throw exception; } - await this.awaitGlobalLoadingIndicatorHidden(); - } - - public async isGlobalLoadingIndicatorVisible() { - log.debug('isGlobalLoadingIndicatorVisible'); - return await testSubjects.exists('globalLoadingIndicator', { timeout: 1500 }); } + await this.awaitGlobalLoadingIndicatorHidden(); + } - public async awaitGlobalLoadingIndicatorHidden() { - await testSubjects.existOrFail('globalLoadingIndicator-hidden', { - allowHidden: true, - timeout: defaultFindTimeout * 10, - }); - } + public async isGlobalLoadingIndicatorVisible() { + this.log.debug('isGlobalLoadingIndicatorVisible'); + return await this.testSubjects.exists('globalLoadingIndicator', { timeout: 1500 }); + } - public async awaitKibanaChrome() { - log.debug('awaitKibanaChrome'); - await testSubjects.find('kibanaChrome', defaultFindTimeout * 10); - } + public async awaitGlobalLoadingIndicatorHidden() { + await this.testSubjects.existOrFail('globalLoadingIndicator-hidden', { + allowHidden: true, + timeout: this.defaultFindTimeout * 10, + }); + } - public async onAppLeaveWarning(ignoreWarning = false) { - await retry.try(async () => { - const warning = await testSubjects.exists('confirmModalTitleText'); - if (warning) { - await testSubjects.click( - ignoreWarning ? 'confirmModalConfirmButton' : 'confirmModalCancelButton' - ); - } - }); - } + public async awaitKibanaChrome() { + this.log.debug('awaitKibanaChrome'); + await this.testSubjects.find('kibanaChrome', this.defaultFindTimeout * 10); } - return new HeaderPage(); + public async onAppLeaveWarning(ignoreWarning = false) { + await this.retry.try(async () => { + const warning = await this.testSubjects.exists('confirmModalTitleText'); + if (warning) { + await this.testSubjects.click( + ignoreWarning ? 'confirmModalConfirmButton' : 'confirmModalCancelButton' + ); + } + }); + } } diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index f03f74ef8c61d9..33de6a33c50f54 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -6,138 +6,134 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function HomePageProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - const find = getService('find'); - const PageObjects = getPageObjects(['common']); +export class HomePageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); + private readonly find = this.ctx.getService('find'); + private readonly common = this.ctx.getPageObject('common'); - class HomePage { - async clickSynopsis(title: string) { - await testSubjects.click(`homeSynopsisLink${title}`); - } - - async doesSynopsisExist(title: string) { - return await testSubjects.exists(`homeSynopsisLink${title}`); - } + async clickSynopsis(title: string) { + await this.testSubjects.click(`homeSynopsisLink${title}`); + } - async doesSampleDataSetExist(id: string) { - return await testSubjects.exists(`sampleDataSetCard${id}`); - } + async doesSynopsisExist(title: string) { + return await this.testSubjects.exists(`homeSynopsisLink${title}`); + } - async isSampleDataSetInstalled(id: string) { - return !(await testSubjects.exists(`addSampleDataSet${id}`)); - } + async doesSampleDataSetExist(id: string) { + return await this.testSubjects.exists(`sampleDataSetCard${id}`); + } - async getVisibileSolutions() { - const solutionPanels = await testSubjects.findAll('~homSolutionPanel', 2000); - const panelAttributes = await Promise.all( - solutionPanels.map((panel) => panel.getAttribute('data-test-subj')) - ); - return panelAttributes.map((attributeValue) => attributeValue.split('homSolutionPanel_')[1]); - } + async isSampleDataSetInstalled(id: string) { + return !(await this.testSubjects.exists(`addSampleDataSet${id}`)); + } - async addSampleDataSet(id: string) { - const isInstalled = await this.isSampleDataSetInstalled(id); - if (!isInstalled) { - await testSubjects.click(`addSampleDataSet${id}`); - await this._waitForSampleDataLoadingAction(id); - } - } + async getVisibileSolutions() { + const solutionPanels = await this.testSubjects.findAll('~homSolutionPanel', 2000); + const panelAttributes = await Promise.all( + solutionPanels.map((panel) => panel.getAttribute('data-test-subj')) + ); + return panelAttributes.map((attributeValue) => attributeValue.split('homSolutionPanel_')[1]); + } - async removeSampleDataSet(id: string) { - // looks like overkill but we're hitting flaky cases where we click but it doesn't remove - await testSubjects.waitForEnabled(`removeSampleDataSet${id}`); - // https://github.com/elastic/kibana/issues/65949 - // Even after waiting for the "Remove" button to be enabled we still have failures - // where it appears the click just didn't work. - await PageObjects.common.sleep(1010); - await testSubjects.click(`removeSampleDataSet${id}`); + async addSampleDataSet(id: string) { + const isInstalled = await this.isSampleDataSetInstalled(id); + if (!isInstalled) { + await this.testSubjects.click(`addSampleDataSet${id}`); await this._waitForSampleDataLoadingAction(id); } + } - // loading action is either uninstall and install - async _waitForSampleDataLoadingAction(id: string) { - const sampleDataCard = await testSubjects.find(`sampleDataSetCard${id}`); - await retry.try(async () => { - // waitForDeletedByCssSelector needs to be inside retry because it will timeout at least once - // before action is complete - await sampleDataCard.waitForDeletedByCssSelector('.euiLoadingSpinner'); - }); - } + async removeSampleDataSet(id: string) { + // looks like overkill but we're hitting flaky cases where we click but it doesn't remove + await this.testSubjects.waitForEnabled(`removeSampleDataSet${id}`); + // https://github.com/elastic/kibana/issues/65949 + // Even after waiting for the "Remove" button to be enabled we still have failures + // where it appears the click just didn't work. + await this.common.sleep(1010); + await this.testSubjects.click(`removeSampleDataSet${id}`); + await this._waitForSampleDataLoadingAction(id); + } - async launchSampleDashboard(id: string) { - await this.launchSampleDataSet(id); - await find.clickByLinkText('Dashboard'); - } + // loading action is either uninstall and install + async _waitForSampleDataLoadingAction(id: string) { + const sampleDataCard = await this.testSubjects.find(`sampleDataSetCard${id}`); + await this.retry.try(async () => { + // waitForDeletedByCssSelector needs to be inside retry because it will timeout at least once + // before action is complete + await sampleDataCard.waitForDeletedByCssSelector('.euiLoadingSpinner'); + }); + } - async launchSampleDataSet(id: string) { - await this.addSampleDataSet(id); - await testSubjects.click(`launchSampleDataSet${id}`); - } + async launchSampleDashboard(id: string) { + await this.launchSampleDataSet(id); + await this.find.clickByLinkText('Dashboard'); + } - async clickAllKibanaPlugins() { - await testSubjects.click('allPlugins'); - } + async launchSampleDataSet(id: string) { + await this.addSampleDataSet(id); + await this.testSubjects.click(`launchSampleDataSet${id}`); + } - async clickVisualizeExplorePlugins() { - await testSubjects.click('tab-data'); - } + async clickAllKibanaPlugins() { + await this.testSubjects.click('allPlugins'); + } - async clickAdminPlugin() { - await testSubjects.click('tab-admin'); - } + async clickVisualizeExplorePlugins() { + await this.testSubjects.click('tab-data'); + } - async clickOnConsole() { - await this.clickSynopsis('console'); - } - async clickOnLogo() { - await testSubjects.click('logo'); - } + async clickAdminPlugin() { + await this.testSubjects.click('tab-admin'); + } - async clickOnAddData() { - await this.clickSynopsis('home_tutorial_directory'); - } + async clickOnConsole() { + await this.clickSynopsis('console'); + } + async clickOnLogo() { + await this.testSubjects.click('logo'); + } - // clicks on Active MQ logs - async clickOnLogsTutorial() { - await this.clickSynopsis('activemqlogs'); - } + async clickOnAddData() { + await this.clickSynopsis('home_tutorial_directory'); + } - // clicks on cloud tutorial link - async clickOnCloudTutorial() { - await testSubjects.click('onCloudTutorial'); - } + // clicks on Active MQ logs + async clickOnLogsTutorial() { + await this.clickSynopsis('activemqlogs'); + } - // click on side nav toggle button to see all of side nav - async clickOnToggleNavButton() { - await testSubjects.click('toggleNavButton'); - } + // clicks on cloud tutorial link + async clickOnCloudTutorial() { + await this.testSubjects.click('onCloudTutorial'); + } - // collapse the observability side nav details - async collapseObservabibilitySideNav() { - await testSubjects.click('collapsibleNavGroup-observability'); - } + // click on side nav toggle button to see all of side nav + async clickOnToggleNavButton() { + await this.testSubjects.click('toggleNavButton'); + } - // dock the side nav - async dockTheSideNav() { - await testSubjects.click('collapsible-nav-lock'); - } + // collapse the observability side nav details + async collapseObservabibilitySideNav() { + await this.testSubjects.click('collapsibleNavGroup-observability'); + } - async loadSavedObjects() { - await retry.try(async () => { - await testSubjects.click('loadSavedObjects'); - const successMsgExists = await testSubjects.exists('loadSavedObjects_success', { - timeout: 5000, - }); - if (!successMsgExists) { - throw new Error('Failed to load saved objects'); - } - }); - } + // dock the side nav + async dockTheSideNav() { + await this.testSubjects.click('collapsible-nav-lock'); } - return new HomePage(); + async loadSavedObjects() { + await this.retry.try(async () => { + await this.testSubjects.click('loadSavedObjects'); + const successMsgExists = await this.testSubjects.exists('loadSavedObjects_success', { + timeout: 5000, + }); + if (!successMsgExists) { + throw new Error('Failed to load saved objects'); + } + }); + } } diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index 413e0aef1444b5..7c06344c1a1ad5 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -6,54 +6,54 @@ * Side Public License, v 1. */ -import { CommonPageProvider } from './common_page'; -import { ConsolePageProvider } from './console_page'; -import { ContextPageProvider } from './context_page'; -import { DashboardPageProvider } from './dashboard_page'; -import { DiscoverPageProvider } from './discover_page'; -import { ErrorPageProvider } from './error_page'; -import { HeaderPageProvider } from './header_page'; -import { HomePageProvider } from './home_page'; -import { NewsfeedPageProvider } from './newsfeed_page'; -import { SettingsPageProvider } from './settings_page'; -import { SharePageProvider } from './share_page'; -import { LoginPageProvider } from './login_page'; -import { TimePickerProvider } from './time_picker'; -import { TimelionPageProvider } from './timelion_page'; -import { VisualBuilderPageProvider } from './visual_builder_page'; -import { VisualizePageProvider } from './visualize_page'; -import { VisualizeEditorPageProvider } from './visualize_editor_page'; -import { VisualizeChartPageProvider } from './visualize_chart_page'; -import { TileMapPageProvider } from './tile_map_page'; -import { TimeToVisualizePageProvider } from './time_to_visualize_page'; -import { TagCloudPageProvider } from './tag_cloud_page'; -import { VegaChartPageProvider } from './vega_chart_page'; -import { SavedObjectsPageProvider } from './management/saved_objects_page'; -import { LegacyDataTableVisProvider } from './legacy/data_table_vis'; +import { CommonPageObject } from './common_page'; +import { ConsolePageObject } from './console_page'; +import { ContextPageObject } from './context_page'; +import { DashboardPageObject } from './dashboard_page'; +import { DiscoverPageObject } from './discover_page'; +import { ErrorPageObject } from './error_page'; +import { HeaderPageObject } from './header_page'; +import { HomePageObject } from './home_page'; +import { NewsfeedPageObject } from './newsfeed_page'; +import { SettingsPageObject } from './settings_page'; +import { SharePageObject } from './share_page'; +import { LoginPageObject } from './login_page'; +import { TimePickerPageObject } from './time_picker'; +import { TimelionPageObject } from './timelion_page'; +import { VisualBuilderPageObject } from './visual_builder_page'; +import { VisualizePageObject } from './visualize_page'; +import { VisualizeEditorPageObject } from './visualize_editor_page'; +import { VisualizeChartPageObject } from './visualize_chart_page'; +import { TileMapPageObject } from './tile_map_page'; +import { TimeToVisualizePageObject } from './time_to_visualize_page'; +import { TagCloudPageObject } from './tag_cloud_page'; +import { VegaChartPageObject } from './vega_chart_page'; +import { SavedObjectsPageObject } from './management/saved_objects_page'; +import { LegacyDataTableVisPageObject } from './legacy/data_table_vis'; export const pageObjects = { - common: CommonPageProvider, - console: ConsolePageProvider, - context: ContextPageProvider, - dashboard: DashboardPageProvider, - discover: DiscoverPageProvider, - error: ErrorPageProvider, - header: HeaderPageProvider, - home: HomePageProvider, - newsfeed: NewsfeedPageProvider, - settings: SettingsPageProvider, - share: SharePageProvider, - legacyDataTableVis: LegacyDataTableVisProvider, - login: LoginPageProvider, - timelion: TimelionPageProvider, - timePicker: TimePickerProvider, - visualBuilder: VisualBuilderPageProvider, - visualize: VisualizePageProvider, - visEditor: VisualizeEditorPageProvider, - visChart: VisualizeChartPageProvider, - tileMap: TileMapPageProvider, - timeToVisualize: TimeToVisualizePageProvider, - tagCloud: TagCloudPageProvider, - vegaChart: VegaChartPageProvider, - savedObjects: SavedObjectsPageProvider, + common: CommonPageObject, + console: ConsolePageObject, + context: ContextPageObject, + dashboard: DashboardPageObject, + discover: DiscoverPageObject, + error: ErrorPageObject, + header: HeaderPageObject, + home: HomePageObject, + newsfeed: NewsfeedPageObject, + settings: SettingsPageObject, + share: SharePageObject, + legacyDataTableVis: LegacyDataTableVisPageObject, + login: LoginPageObject, + timelion: TimelionPageObject, + timePicker: TimePickerPageObject, + visualBuilder: VisualBuilderPageObject, + visualize: VisualizePageObject, + visEditor: VisualizeEditorPageObject, + visChart: VisualizeChartPageObject, + tileMap: TileMapPageObject, + timeToVisualize: TimeToVisualizePageObject, + tagCloud: TagCloudPageObject, + vegaChart: VegaChartPageObject, + savedObjects: SavedObjectsPageObject, }; diff --git a/test/functional/page_objects/legacy/data_table_vis.ts b/test/functional/page_objects/legacy/data_table_vis.ts index ef787263f2ab9d..122409f28de900 100644 --- a/test/functional/page_objects/legacy/data_table_vis.ts +++ b/test/functional/page_objects/legacy/data_table_vis.ts @@ -6,80 +6,79 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from 'test/functional/ftr_provider_context'; -import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; +import { FtrService } from '../../ftr_provider_context'; +import { WebElementWrapper } from '../../services/lib/web_element_wrapper'; -export function LegacyDataTableVisProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); +export class LegacyDataTableVisPageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); - class LegacyDataTableVis { - /** - * Converts the table data into nested array - * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] - * @param element table - */ - private async getDataFromElement(element: WebElementWrapper): Promise { - const $ = await element.parseDomContent(); - return $('tr') - .toArray() - .map((row) => - $(row) - .find('td') - .toArray() - .map((cell) => - $(cell) - .text() - .replace(/ /g, '') - .trim() - ) - ); - } - - public async getTableVisContent({ stripEmptyRows = true } = {}) { - return await retry.try(async () => { - const container = await testSubjects.find('tableVis'); - const allTables = await testSubjects.findAllDescendant('paginated-table-body', container); + /** + * Converts the table data into nested array + * [ [cell1_in_row1, cell2_in_row1], [cell1_in_row2, cell2_in_row2] ] + * @param element table + */ + private async getDataFromElement(element: WebElementWrapper): Promise { + const $ = await element.parseDomContent(); + return $('tr') + .toArray() + .map((row) => + $(row) + .find('td') + .toArray() + .map((cell) => + $(cell) + .text() + .replace(/ /g, '') + .trim() + ) + ); + } - if (allTables.length === 0) { - return []; - } + public async getTableVisContent({ stripEmptyRows = true } = {}) { + return await this.retry.try(async () => { + const container = await this.testSubjects.find('tableVis'); + const allTables = await this.testSubjects.findAllDescendant( + 'paginated-table-body', + container + ); - const allData = await Promise.all( - allTables.map(async (t) => { - let data = await this.getDataFromElement(t); - if (stripEmptyRows) { - data = data.filter( - (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) - ); - } - return data; - }) - ); + if (allTables.length === 0) { + return []; + } - if (allTables.length === 1) { - // If there was only one table we return only the data for that table - // This prevents an unnecessary array around that single table, which - // is the case we have in most tests. - return allData[0]; - } + const allData = await Promise.all( + allTables.map(async (t) => { + let data = await this.getDataFromElement(t); + if (stripEmptyRows) { + data = data.filter( + (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) + ); + } + return data; + }) + ); - return allData; - }); - } + if (allTables.length === 1) { + // If there was only one table we return only the data for that table + // This prevents an unnecessary array around that single table, which + // is the case we have in most tests. + return allData[0]; + } - public async filterOnTableCell(columnIndex: number, rowIndex: number) { - await retry.try(async () => { - const tableVis = await testSubjects.find('tableVis'); - const cell = await tableVis.findByCssSelector( - `tbody tr:nth-child(${rowIndex}) td:nth-child(${columnIndex})` - ); - await cell.moveMouseTo(); - const filterBtn = await testSubjects.findDescendant('filterForCellValue', cell); - await filterBtn.click(); - }); - } + return allData; + }); } - return new LegacyDataTableVis(); + public async filterOnTableCell(columnIndex: number, rowIndex: number) { + await this.retry.try(async () => { + const tableVis = await this.testSubjects.find('tableVis'); + const cell = await tableVis.findByCssSelector( + `tbody tr:nth-child(${rowIndex}) td:nth-child(${columnIndex})` + ); + await cell.moveMouseTo(); + const filterBtn = await this.testSubjects.findDescendant('filterForCellValue', cell); + await filterBtn.click(); + }); + } } diff --git a/test/functional/page_objects/login_page.ts b/test/functional/page_objects/login_page.ts index 606ddf4643c405..5318a2b2d0c154 100644 --- a/test/functional/page_objects/login_page.ts +++ b/test/functional/page_objects/login_page.ts @@ -7,65 +7,61 @@ */ import { delay } from 'bluebird'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function LoginPageProvider({ getService }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const log = getService('log'); - const find = getService('find'); +export class LoginPageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly log = this.ctx.getService('log'); + private readonly find = this.ctx.getService('find'); - const regularLogin = async (user: string, pwd: string) => { - await testSubjects.setValue('loginUsername', user); - await testSubjects.setValue('loginPassword', pwd); - await testSubjects.click('loginSubmit'); - await find.waitForDeletedByCssSelector('.kibanaWelcomeLogo'); - await find.byCssSelector('[data-test-subj="kibanaChrome"]', 60000); // 60 sec waiting - }; - - const samlLogin = async (user: string, pwd: string) => { - try { - await find.clickByButtonText('Login using SAML'); - await find.setValue('input[name="email"]', user); - await find.setValue('input[type="password"]', pwd); - await find.clickByCssSelector('.auth0-label-submit'); - await find.byCssSelector('[data-test-subj="kibanaChrome"]', 60000); // 60 sec waiting - } catch (err) { - log.debug(`${err} \nFailed to find Auth0 login page, trying the Auth0 last login page`); - await find.clickByCssSelector('.auth0-lock-social-button'); + async login(user: string, pwd: string) { + const loginType = process.env.VM || ''; + if (loginType.includes('oidc') || loginType.includes('saml')) { + await this.samlLogin(user, pwd); + return; } - }; - class LoginPage { - async login(user: string, pwd: string) { - const loginType = process.env.VM || ''; - if (loginType.includes('oidc') || loginType.includes('saml')) { - await samlLogin(user, pwd); - return; - } + await this.regularLogin(user, pwd); + } - await regularLogin(user, pwd); - } + async logoutLogin(user: string, pwd: string) { + await this.logout(); + await this.sleep(3002); + await this.login(user, pwd); + } - async logoutLogin(user: string, pwd: string) { - await this.logout(); - await this.sleep(3002); - await this.login(user, pwd); - } + async logout() { + await this.testSubjects.click('userMenuButton'); + await this.sleep(500); + await this.testSubjects.click('logoutLink'); + this.log.debug('### found and clicked log out--------------------------'); + await this.sleep(8002); + } - async logout() { - await testSubjects.click('userMenuButton'); - await this.sleep(500); - await testSubjects.click('logoutLink'); - log.debug('### found and clicked log out--------------------------'); - await this.sleep(8002); - } + async sleep(sleepMilliseconds: number) { + this.log.debug(`... sleep(${sleepMilliseconds}) start`); + await delay(sleepMilliseconds); + this.log.debug(`... sleep(${sleepMilliseconds}) end`); + } - async sleep(sleepMilliseconds: number) { - log.debug(`... sleep(${sleepMilliseconds}) start`); - await delay(sleepMilliseconds); - log.debug(`... sleep(${sleepMilliseconds}) end`); - } + private async regularLogin(user: string, pwd: string) { + await this.testSubjects.setValue('loginUsername', user); + await this.testSubjects.setValue('loginPassword', pwd); + await this.testSubjects.click('loginSubmit'); + await this.find.waitForDeletedByCssSelector('.kibanaWelcomeLogo'); + await this.find.byCssSelector('[data-test-subj="kibanaChrome"]', 60000); // 60 sec waiting } - return new LoginPage(); + private async samlLogin(user: string, pwd: string) { + try { + await this.find.clickByButtonText('Login using SAML'); + await this.find.setValue('input[name="email"]', user); + await this.find.setValue('input[type="password"]', pwd); + await this.find.clickByCssSelector('.auth0-label-submit'); + await this.find.byCssSelector('[data-test-subj="kibanaChrome"]', 60000); // 60 sec waiting + } catch (err) { + this.log.debug(`${err} \nFailed to find Auth0 login page, trying the Auth0 last login page`); + await this.find.clickByCssSelector('.auth0-lock-social-button'); + } + } } diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index fc4de6ed7f82f8..9f48a6f57c8d81 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -8,328 +8,325 @@ import { keyBy } from 'lodash'; import { map as mapAsync } from 'bluebird'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const retry = getService('retry'); - const browser = getService('browser'); - const find = getService('find'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['header', 'common']); - - class SavedObjectsPage { - async searchForObject(objectName: string) { - const searchBox = await testSubjects.find('savedObjectSearchBar'); - await searchBox.clearValue(); - await searchBox.type(objectName); - await searchBox.pressKeys(browser.keys.ENTER); - await PageObjects.header.waitUntilLoadingHasFinished(); - await this.waitTableIsLoaded(); - } - - async getCurrentSearchValue() { - const searchBox = await testSubjects.find('savedObjectSearchBar'); - return await searchBox.getAttribute('value'); - } +import { FtrService } from '../../ftr_provider_context'; + +export class SavedObjectsPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly find = this.ctx.getService('find'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); + + async searchForObject(objectName: string) { + const searchBox = await this.testSubjects.find('savedObjectSearchBar'); + await searchBox.clearValue(); + await searchBox.type(objectName); + await searchBox.pressKeys(this.browser.keys.ENTER); + await this.header.waitUntilLoadingHasFinished(); + await this.waitTableIsLoaded(); + } - async importFile(path: string, overwriteAll = true) { - log.debug(`importFile(${path})`); + async getCurrentSearchValue() { + const searchBox = await this.testSubjects.find('savedObjectSearchBar'); + return await searchBox.getAttribute('value'); + } - log.debug(`Clicking importObjects`); - await testSubjects.click('importObjects'); - await PageObjects.common.setFileInputPath(path); + async importFile(path: string, overwriteAll = true) { + this.log.debug(`importFile(${path})`); - if (!overwriteAll) { - log.debug(`Toggling overwriteAll`); - const radio = await testSubjects.find( - 'savedObjectsManagement-importModeControl-overwriteRadioGroup' - ); - // a radio button consists of a div tag that contains an input, a div, and a label - // we can't click the input directly, need to go up one level and click the parent div - const div = await radio.findByXpath("//div[input[@id='overwriteDisabled']]"); - await div.click(); - } else { - log.debug(`Leaving overwriteAll alone`); - } - await testSubjects.click('importSavedObjectsImportBtn'); - log.debug(`done importing the file`); + this.log.debug(`Clicking importObjects`); + await this.testSubjects.click('importObjects'); + await this.common.setFileInputPath(path); - // Wait for all the saves to happen - await PageObjects.header.waitUntilLoadingHasFinished(); + if (!overwriteAll) { + this.log.debug(`Toggling overwriteAll`); + const radio = await this.testSubjects.find( + 'savedObjectsManagement-importModeControl-overwriteRadioGroup' + ); + // a radio button consists of a div tag that contains an input, a div, and a label + // we can't click the input directly, need to go up one level and click the parent div + const div = await radio.findByXpath("//div[input[@id='overwriteDisabled']]"); + await div.click(); + } else { + this.log.debug(`Leaving overwriteAll alone`); } + await this.testSubjects.click('importSavedObjectsImportBtn'); + this.log.debug(`done importing the file`); - async checkImportSucceeded() { - await testSubjects.existOrFail('importSavedObjectsSuccess', { timeout: 20000 }); - } + // Wait for all the saves to happen + await this.header.waitUntilLoadingHasFinished(); + } - async checkNoneImported() { - await testSubjects.existOrFail('importSavedObjectsSuccessNoneImported', { timeout: 20000 }); - } + async checkImportSucceeded() { + await this.testSubjects.existOrFail('importSavedObjectsSuccess', { timeout: 20000 }); + } - async checkImportConflictsWarning() { - await testSubjects.existOrFail('importSavedObjectsConflictsWarning', { timeout: 20000 }); - } + async checkNoneImported() { + await this.testSubjects.existOrFail('importSavedObjectsSuccessNoneImported', { + timeout: 20000, + }); + } - async checkImportLegacyWarning() { - await testSubjects.existOrFail('importSavedObjectsLegacyWarning', { timeout: 20000 }); - } + async checkImportConflictsWarning() { + await this.testSubjects.existOrFail('importSavedObjectsConflictsWarning', { timeout: 20000 }); + } - async checkImportFailedWarning() { - await testSubjects.existOrFail('importSavedObjectsFailedWarning', { timeout: 20000 }); - } + async checkImportLegacyWarning() { + await this.testSubjects.existOrFail('importSavedObjectsLegacyWarning', { timeout: 20000 }); + } - async checkImportError() { - await testSubjects.existOrFail('importSavedObjectsErrorText', { timeout: 20000 }); - } + async checkImportFailedWarning() { + await this.testSubjects.existOrFail('importSavedObjectsFailedWarning', { timeout: 20000 }); + } - async getImportErrorText() { - return await testSubjects.getVisibleText('importSavedObjectsErrorText'); - } + async checkImportError() { + await this.testSubjects.existOrFail('importSavedObjectsErrorText', { timeout: 20000 }); + } - async clickImportDone() { - await testSubjects.click('importSavedObjectsDoneBtn'); - await this.waitTableIsLoaded(); - } + async getImportErrorText() { + return await this.testSubjects.getVisibleText('importSavedObjectsErrorText'); + } - async clickConfirmChanges() { - await testSubjects.click('importSavedObjectsConfirmBtn'); - } + async clickImportDone() { + await this.testSubjects.click('importSavedObjectsDoneBtn'); + await this.waitTableIsLoaded(); + } - async waitTableIsLoaded() { - return retry.try(async () => { - const isLoaded = await find.existsByDisplayedByCssSelector( - '*[data-test-subj="savedObjectsTable"] :not(.euiBasicTable-loading)' - ); + async clickConfirmChanges() { + await this.testSubjects.click('importSavedObjectsConfirmBtn'); + } - if (isLoaded) { - return true; - } else { - throw new Error('Waiting'); - } - }); - } + async waitTableIsLoaded() { + return this.retry.try(async () => { + const isLoaded = await this.find.existsByDisplayedByCssSelector( + '*[data-test-subj="savedObjectsTable"] :not(.euiBasicTable-loading)' + ); - async clickRelationshipsByTitle(title: string) { - const table = keyBy(await this.getElementsInTable(), 'title'); - // should we check if table size > 0 and log error if not? - if (table[title].menuElement) { - log.debug(`we found a context menu element for (${title}) so click it`); - await table[title].menuElement?.click(); - // Wait for context menu to render - const menuPanel = await find.byCssSelector('.euiContextMenuPanel'); - await (await menuPanel.findByTestSubject('savedObjectsTableAction-relationships')).click(); + if (isLoaded) { + return true; } else { - log.debug( - `we didn't find a menu element so should be a relastionships element for (${title}) to click` - ); - // or the action elements are on the row without the menu - await table[title].relationshipsElement?.click(); + throw new Error('Waiting'); } - } + }); + } - async setOverriddenIndexPatternValue(oldName: string, newName: string) { - const select = await testSubjects.find(`managementChangeIndexSelection-${oldName}`); - const option = await testSubjects.findDescendant(`indexPatternOption-${newName}`, select); - await option.click(); + async clickRelationshipsByTitle(title: string) { + const table = keyBy(await this.getElementsInTable(), 'title'); + // should we check if table size > 0 and log error if not? + if (table[title].menuElement) { + this.log.debug(`we found a context menu element for (${title}) so click it`); + await table[title].menuElement?.click(); + // Wait for context menu to render + const menuPanel = await this.find.byCssSelector('.euiContextMenuPanel'); + await (await menuPanel.findByTestSubject('savedObjectsTableAction-relationships')).click(); + } else { + this.log.debug( + `we didn't find a menu element so should be a relastionships element for (${title}) to click` + ); + // or the action elements are on the row without the menu + await table[title].relationshipsElement?.click(); } + } - async clickCopyToSpaceByTitle(title: string) { - const table = keyBy(await this.getElementsInTable(), 'title'); - // should we check if table size > 0 and log error if not? - if (table[title].menuElement) { - log.debug(`we found a context menu element for (${title}) so click it`); - await table[title].menuElement?.click(); - // Wait for context menu to render - const menuPanel = await find.byCssSelector('.euiContextMenuPanel'); - await ( - await menuPanel.findByTestSubject('savedObjectsTableAction-copy_saved_objects_to_space') - ).click(); - } else { - log.debug( - `we didn't find a menu element so should be a "copy to space" element for (${title}) to click` - ); - // or the action elements are on the row without the menu - await table[title].copySaveObjectsElement?.click(); - } - } + async setOverriddenIndexPatternValue(oldName: string, newName: string) { + const select = await this.testSubjects.find(`managementChangeIndexSelection-${oldName}`); + const option = await this.testSubjects.findDescendant(`indexPatternOption-${newName}`, select); + await option.click(); + } - async clickInspectByTitle(title: string) { - const table = keyBy(await this.getElementsInTable(), 'title'); - if (table[title].menuElement) { - await table[title].menuElement?.click(); - // Wait for context menu to render - const menuPanel = await find.byCssSelector('.euiContextMenuPanel'); - const panelButton = await menuPanel.findByTestSubject('savedObjectsTableAction-inspect'); - await panelButton.click(); - } else { - // or the action elements are on the row without the menu - await table[title].copySaveObjectsElement?.click(); - } + async clickCopyToSpaceByTitle(title: string) { + const table = keyBy(await this.getElementsInTable(), 'title'); + // should we check if table size > 0 and log error if not? + if (table[title].menuElement) { + this.log.debug(`we found a context menu element for (${title}) so click it`); + await table[title].menuElement?.click(); + // Wait for context menu to render + const menuPanel = await this.find.byCssSelector('.euiContextMenuPanel'); + await ( + await menuPanel.findByTestSubject('savedObjectsTableAction-copy_saved_objects_to_space') + ).click(); + } else { + this.log.debug( + `we didn't find a menu element so should be a "copy to space" element for (${title}) to click` + ); + // or the action elements are on the row without the menu + await table[title].copySaveObjectsElement?.click(); } + } - async clickCheckboxByTitle(title: string) { - const table = keyBy(await this.getElementsInTable(), 'title'); - // should we check if table size > 0 and log error if not? - await table[title].checkbox.click(); + async clickInspectByTitle(title: string) { + const table = keyBy(await this.getElementsInTable(), 'title'); + if (table[title].menuElement) { + await table[title].menuElement?.click(); + // Wait for context menu to render + const menuPanel = await this.find.byCssSelector('.euiContextMenuPanel'); + const panelButton = await menuPanel.findByTestSubject('savedObjectsTableAction-inspect'); + await panelButton.click(); + } else { + // or the action elements are on the row without the menu + await table[title].copySaveObjectsElement?.click(); } + } - async getObjectTypeByTitle(title: string) { - const table = keyBy(await this.getElementsInTable(), 'title'); - // should we check if table size > 0 and log error if not? - return table[title].objectType; - } + async clickCheckboxByTitle(title: string) { + const table = keyBy(await this.getElementsInTable(), 'title'); + // should we check if table size > 0 and log error if not? + await table[title].checkbox.click(); + } - async getElementsInTable() { - const rows = await testSubjects.findAll('~savedObjectsTableRow'); - return mapAsync(rows, async (row) => { - const checkbox = await row.findByCssSelector('[data-test-subj*="checkboxSelectRow"]'); - // return the object type aria-label="index patterns" - const objectType = await row.findByTestSubject('objectType'); - const titleElement = await row.findByTestSubject('savedObjectsTableRowTitle'); - // not all rows have inspect button - Advanced Settings objects don't - // Advanced Settings has 2 actions, - // data-test-subj="savedObjectsTableAction-relationships" - // data-test-subj="savedObjectsTableAction-copy_saved_objects_to_space" - // Some other objects have the ... - // data-test-subj="euiCollapsedItemActionsButton" - // Maybe some objects still have the inspect element visible? - // !!! Also note that since we don't have spaces on OSS, the actions for the same object can be different depending on OSS or not - let menuElement = null; - let inspectElement = null; - let relationshipsElement = null; - let copySaveObjectsElement = null; - const actions = await row.findByClassName('euiTableRowCell--hasActions'); - // getting the innerHTML and checking if it 'includes' a string is faster than a timeout looking for each element - const actionsHTML = await actions.getAttribute('innerHTML'); - if (actionsHTML.includes('euiCollapsedItemActionsButton')) { - menuElement = await row.findByTestSubject('euiCollapsedItemActionsButton'); - } - if (actionsHTML.includes('savedObjectsTableAction-inspect')) { - inspectElement = await row.findByTestSubject('savedObjectsTableAction-inspect'); - } - if (actionsHTML.includes('savedObjectsTableAction-relationships')) { - relationshipsElement = await row.findByTestSubject( - 'savedObjectsTableAction-relationships' - ); - } - if (actionsHTML.includes('savedObjectsTableAction-copy_saved_objects_to_space')) { - copySaveObjectsElement = await row.findByTestSubject( - 'savedObjectsTableAction-copy_saved_objects_to_space' - ); - } - return { - checkbox, - objectType: await objectType.getAttribute('aria-label'), - titleElement, - title: await titleElement.getVisibleText(), - menuElement, - inspectElement, - relationshipsElement, - copySaveObjectsElement, - }; - }); - } + async getObjectTypeByTitle(title: string) { + const table = keyBy(await this.getElementsInTable(), 'title'); + // should we check if table size > 0 and log error if not? + return table[title].objectType; + } - async getRowTitles() { - await this.waitTableIsLoaded(); - const table = await testSubjects.find('savedObjectsTable'); - const $ = await table.parseDomContent(); - return $.findTestSubjects('savedObjectsTableRowTitle') - .toArray() - .map((cell) => $(cell).find('.euiTableCellContent').text()); - } + async getElementsInTable() { + const rows = await this.testSubjects.findAll('~savedObjectsTableRow'); + return mapAsync(rows, async (row) => { + const checkbox = await row.findByCssSelector('[data-test-subj*="checkboxSelectRow"]'); + // return the object type aria-label="index patterns" + const objectType = await row.findByTestSubject('objectType'); + const titleElement = await row.findByTestSubject('savedObjectsTableRowTitle'); + // not all rows have inspect button - Advanced Settings objects don't + // Advanced Settings has 2 actions, + // data-test-subj="savedObjectsTableAction-relationships" + // data-test-subj="savedObjectsTableAction-copy_saved_objects_to_space" + // Some other objects have the ... + // data-test-subj="euiCollapsedItemActionsButton" + // Maybe some objects still have the inspect element visible? + // !!! Also note that since we don't have spaces on OSS, the actions for the same object can be different depending on OSS or not + let menuElement = null; + let inspectElement = null; + let relationshipsElement = null; + let copySaveObjectsElement = null; + const actions = await row.findByClassName('euiTableRowCell--hasActions'); + // getting the innerHTML and checking if it 'includes' a string is faster than a timeout looking for each element + const actionsHTML = await actions.getAttribute('innerHTML'); + if (actionsHTML.includes('euiCollapsedItemActionsButton')) { + menuElement = await row.findByTestSubject('euiCollapsedItemActionsButton'); + } + if (actionsHTML.includes('savedObjectsTableAction-inspect')) { + inspectElement = await row.findByTestSubject('savedObjectsTableAction-inspect'); + } + if (actionsHTML.includes('savedObjectsTableAction-relationships')) { + relationshipsElement = await row.findByTestSubject('savedObjectsTableAction-relationships'); + } + if (actionsHTML.includes('savedObjectsTableAction-copy_saved_objects_to_space')) { + copySaveObjectsElement = await row.findByTestSubject( + 'savedObjectsTableAction-copy_saved_objects_to_space' + ); + } + return { + checkbox, + objectType: await objectType.getAttribute('aria-label'), + titleElement, + title: await titleElement.getVisibleText(), + menuElement, + inspectElement, + relationshipsElement, + copySaveObjectsElement, + }; + }); + } - async getRelationshipFlyout() { - const rows = await testSubjects.findAll('relationshipsTableRow'); - return mapAsync(rows, async (row) => { - const objectType = await row.findByTestSubject('relationshipsObjectType'); - const relationship = await row.findByTestSubject('directRelationship'); - const titleElement = await row.findByTestSubject('relationshipsTitle'); - const inspectElement = await row.findByTestSubject('relationshipsTableAction-inspect'); - return { - objectType: await objectType.getAttribute('aria-label'), - relationship: await relationship.getVisibleText(), - titleElement, - title: await titleElement.getVisibleText(), - inspectElement, - }; - }); - } + async getRowTitles() { + await this.waitTableIsLoaded(); + const table = await this.testSubjects.find('savedObjectsTable'); + const $ = await table.parseDomContent(); + return $.findTestSubjects('savedObjectsTableRowTitle') + .toArray() + .map((cell) => $(cell).find('.euiTableCellContent').text()); + } - async getInvalidRelations() { - const rows = await testSubjects.findAll('invalidRelationshipsTableRow'); - return mapAsync(rows, async (row) => { - const objectType = await row.findByTestSubject('relationshipsObjectType'); - const objectId = await row.findByTestSubject('relationshipsObjectId'); - const relationship = await row.findByTestSubject('directRelationship'); - const error = await row.findByTestSubject('relationshipsError'); + async getRelationshipFlyout() { + const rows = await this.testSubjects.findAll('relationshipsTableRow'); + return mapAsync(rows, async (row) => { + const objectType = await row.findByTestSubject('relationshipsObjectType'); + const relationship = await row.findByTestSubject('directRelationship'); + const titleElement = await row.findByTestSubject('relationshipsTitle'); + const inspectElement = await row.findByTestSubject('relationshipsTableAction-inspect'); + return { + objectType: await objectType.getAttribute('aria-label'), + relationship: await relationship.getVisibleText(), + titleElement, + title: await titleElement.getVisibleText(), + inspectElement, + }; + }); + } + + async getInvalidRelations() { + const rows = await this.testSubjects.findAll('invalidRelationshipsTableRow'); + return mapAsync(rows, async (row) => { + const objectType = await row.findByTestSubject('relationshipsObjectType'); + const objectId = await row.findByTestSubject('relationshipsObjectId'); + const relationship = await row.findByTestSubject('directRelationship'); + const error = await row.findByTestSubject('relationshipsError'); + return { + type: await objectType.getVisibleText(), + id: await objectId.getVisibleText(), + relationship: await relationship.getVisibleText(), + error: await error.getVisibleText(), + }; + }); + } + + async getTableSummary() { + const table = await this.testSubjects.find('savedObjectsTable'); + const $ = await table.parseDomContent(); + return $('tbody tr') + .toArray() + .map((row) => { return { - type: await objectType.getVisibleText(), - id: await objectId.getVisibleText(), - relationship: await relationship.getVisibleText(), - error: await error.getVisibleText(), + title: $(row).find('td:nth-child(3) .euiTableCellContent').text(), + canViewInApp: Boolean($(row).find('td:nth-child(3) a').length), }; }); - } + } - async getTableSummary() { - const table = await testSubjects.find('savedObjectsTable'); - const $ = await table.parseDomContent(); - return $('tbody tr') - .toArray() - .map((row) => { - return { - title: $(row).find('td:nth-child(3) .euiTableCellContent').text(), - canViewInApp: Boolean($(row).find('td:nth-child(3) a').length), - }; - }); - } + async clickTableSelectAll() { + await this.testSubjects.click('checkboxSelectAll'); + } - async clickTableSelectAll() { - await testSubjects.click('checkboxSelectAll'); - } + async canBeDeleted() { + return await this.testSubjects.isEnabled('savedObjectsManagementDelete'); + } - async canBeDeleted() { - return await testSubjects.isEnabled('savedObjectsManagementDelete'); + async clickDelete({ confirmDelete = true }: { confirmDelete?: boolean } = {}) { + await this.testSubjects.click('savedObjectsManagementDelete'); + if (confirmDelete) { + await this.testSubjects.click('confirmModalConfirmButton'); + await this.waitTableIsLoaded(); } + } - async clickDelete({ confirmDelete = true }: { confirmDelete?: boolean } = {}) { - await testSubjects.click('savedObjectsManagementDelete'); - if (confirmDelete) { - await testSubjects.click('confirmModalConfirmButton'); - await this.waitTableIsLoaded(); - } - } + async getImportWarnings() { + const elements = await this.testSubjects.findAll('importSavedObjectsWarning'); + return Promise.all( + elements.map(async (element) => { + const message = await element + .findByClassName('euiCallOutHeader__title') + .then((titleEl) => titleEl.getVisibleText()); + const buttons = await element.findAllByClassName('euiButton'); + return { + message, + type: buttons.length ? 'action_required' : 'simple', + }; + }) + ); + } - async getImportWarnings() { - const elements = await testSubjects.findAll('importSavedObjectsWarning'); - return Promise.all( - elements.map(async (element) => { - const message = await element - .findByClassName('euiCallOutHeader__title') - .then((titleEl) => titleEl.getVisibleText()); - const buttons = await element.findAllByClassName('euiButton'); - return { - message, - type: buttons.length ? 'action_required' : 'simple', - }; - }) - ); + async getImportErrorsCount() { + this.log.debug(`Toggling overwriteAll`); + const errorCountNode = await this.testSubjects.find('importSavedObjectsErrorsCount'); + const errorCountText = await errorCountNode.getVisibleText(); + const match = errorCountText.match(/(\d)+/); + if (!match) { + throw Error(`unable to parse error count from text ${errorCountText}`); } - async getImportErrorsCount() { - log.debug(`Toggling overwriteAll`); - const errorCountNode = await testSubjects.find('importSavedObjectsErrorsCount'); - const errorCountText = await errorCountNode.getVisibleText(); - const match = errorCountText.match(/(\d)+/); - if (!match) { - throw Error(`unable to parse error count from text ${errorCountText}`); - } - - return +match[1]; - } + return +match[1]; } - - return new SavedObjectsPage(); } diff --git a/test/functional/page_objects/newsfeed_page.ts b/test/functional/page_objects/newsfeed_page.ts index 1fa9bb5b900020..3a4bbee9245528 100644 --- a/test/functional/page_objects/newsfeed_page.ts +++ b/test/functional/page_objects/newsfeed_page.ts @@ -6,58 +6,54 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; - -export function NewsfeedPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const find = getService('find'); - const retry = getService('retry'); - const flyout = getService('flyout'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['common']); - - class NewsfeedPage { - async resetPage() { - await PageObjects.common.navigateToUrl('home', '', { useActualUrl: true }); - } - - async closeNewsfeedPanel() { - await flyout.ensureClosed('NewsfeedFlyout'); - log.debug('clickNewsfeed icon'); - await retry.waitFor('newsfeed flyout', async () => { - if (await testSubjects.exists('NewsfeedFlyout')) { - await testSubjects.click('NewsfeedFlyout > euiFlyoutCloseButton'); - return false; - } - return true; - }); - } +import { FtrService } from '../ftr_provider_context'; + +export class NewsfeedPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly find = this.ctx.getService('find'); + private readonly retry = this.ctx.getService('retry'); + private readonly flyout = this.ctx.getService('flyout'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly common = this.ctx.getPageObject('common'); + + async resetPage() { + await this.common.navigateToUrl('home', '', { useActualUrl: true }); + } - async openNewsfeedPanel() { - log.debug('clickNewsfeed icon'); - return await testSubjects.exists('NewsfeedFlyout'); - } + async closeNewsfeedPanel() { + await this.flyout.ensureClosed('NewsfeedFlyout'); + this.log.debug('clickNewsfeed icon'); + await this.retry.waitFor('newsfeed flyout', async () => { + if (await this.testSubjects.exists('NewsfeedFlyout')) { + await this.testSubjects.click('NewsfeedFlyout > euiFlyoutCloseButton'); + return false; + } + return true; + }); + } - async getRedButtonSign() { - return await find.existsByCssSelector('.euiHeaderSectionItemButton__notification--dot'); - } + async openNewsfeedPanel() { + this.log.debug('clickNewsfeed icon'); + return await this.testSubjects.exists('NewsfeedFlyout'); + } - async getNewsfeedList() { - const list = await testSubjects.find('NewsfeedFlyout'); - const cells = await list.findAllByTestSubject('newsHeadAlert'); + async getRedButtonSign() { + return await this.find.existsByCssSelector('.euiHeaderSectionItemButton__notification--dot'); + } - const objects = []; - for (const cell of cells) { - objects.push(await cell.getVisibleText()); - } + async getNewsfeedList() { + const list = await this.testSubjects.find('NewsfeedFlyout'); + const cells = await list.findAllByTestSubject('newsHeadAlert'); - return objects; + const objects = []; + for (const cell of cells) { + objects.push(await cell.getVisibleText()); } - async openNewsfeedEmptyPanel() { - return await testSubjects.exists('emptyNewsfeed'); - } + return objects; } - return new NewsfeedPage(); + async openNewsfeedEmptyPanel() { + return await this.testSubjects.exists('emptyNewsfeed'); + } } diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 699165a51ca8c9..7d7da79b4a3974 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -8,731 +8,730 @@ import { map as mapAsync } from 'bluebird'; import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; - -export function SettingsPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const retry = getService('retry'); - const browser = getService('browser'); - const find = getService('find'); - const flyout = getService('flyout'); - const testSubjects = getService('testSubjects'); - const comboBox = getService('comboBox'); - const PageObjects = getPageObjects(['header', 'common', 'savedObjects']); - - class SettingsPage { - async clickNavigation() { - await find.clickDisplayedByCssSelector('.app-link:nth-child(5) a'); - } +import { FtrService } from '../ftr_provider_context'; + +export class SettingsPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly find = this.ctx.getService('find'); + private readonly flyout = this.ctx.getService('flyout'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly comboBox = this.ctx.getService('comboBox'); + private readonly header = this.ctx.getPageObject('header'); + private readonly common = this.ctx.getPageObject('common'); + private readonly savedObjects = this.ctx.getPageObject('savedObjects'); + + async clickNavigation() { + await this.find.clickDisplayedByCssSelector('.app-link:nth-child(5) a'); + } - async clickLinkText(text: string) { - await find.clickByDisplayedLinkText(text); - } + async clickLinkText(text: string) { + await this.find.clickByDisplayedLinkText(text); + } - async clickKibanaSettings() { - await testSubjects.click('settings'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('managementSettingsTitle'); - } + async clickKibanaSettings() { + await this.testSubjects.click('settings'); + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.existOrFail('managementSettingsTitle'); + } - async clickKibanaSavedObjects() { - await testSubjects.click('objects'); - await PageObjects.savedObjects.waitTableIsLoaded(); - } + async clickKibanaSavedObjects() { + await this.testSubjects.click('objects'); + await this.savedObjects.waitTableIsLoaded(); + } - async clickKibanaIndexPatterns() { - log.debug('clickKibanaIndexPatterns link'); - await testSubjects.click('indexPatterns'); + async clickKibanaIndexPatterns() { + this.log.debug('clickKibanaIndexPatterns link'); + await this.testSubjects.click('indexPatterns'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + await this.header.waitUntilLoadingHasFinished(); + } - async getAdvancedSettings(propertyName: string) { - log.debug('in getAdvancedSettings'); - return await testSubjects.getAttribute(`advancedSetting-editField-${propertyName}`, 'value'); - } + async getAdvancedSettings(propertyName: string) { + this.log.debug('in getAdvancedSettings'); + return await this.testSubjects.getAttribute( + `advancedSetting-editField-${propertyName}`, + 'value' + ); + } - async expectDisabledAdvancedSetting(propertyName: string) { - expect( - await testSubjects.getAttribute(`advancedSetting-editField-${propertyName}`, 'disabled') - ).to.eql('true'); - } + async expectDisabledAdvancedSetting(propertyName: string) { + expect( + await this.testSubjects.getAttribute(`advancedSetting-editField-${propertyName}`, 'disabled') + ).to.eql('true'); + } - async getAdvancedSettingCheckbox(propertyName: string) { - log.debug('in getAdvancedSettingCheckbox'); - return await testSubjects.getAttribute( - `advancedSetting-editField-${propertyName}`, - 'checked' - ); - } + async getAdvancedSettingCheckbox(propertyName: string) { + this.log.debug('in getAdvancedSettingCheckbox'); + return await this.testSubjects.getAttribute( + `advancedSetting-editField-${propertyName}`, + 'checked' + ); + } - async clearAdvancedSettings(propertyName: string) { - await testSubjects.click(`advancedSetting-resetField-${propertyName}`); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.click(`advancedSetting-saveButton`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async clearAdvancedSettings(propertyName: string) { + await this.testSubjects.click(`advancedSetting-resetField-${propertyName}`); + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.click(`advancedSetting-saveButton`); + await this.header.waitUntilLoadingHasFinished(); + } - async setAdvancedSettingsSelect(propertyName: string, propertyValue: string) { - await find.clickByCssSelector( - `[data-test-subj="advancedSetting-editField-${propertyName}"] option[value="${propertyValue}"]` - ); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.click(`advancedSetting-saveButton`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async setAdvancedSettingsSelect(propertyName: string, propertyValue: string) { + await this.find.clickByCssSelector( + `[data-test-subj="advancedSetting-editField-${propertyName}"] option[value="${propertyValue}"]` + ); + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.click(`advancedSetting-saveButton`); + await this.header.waitUntilLoadingHasFinished(); + } - async setAdvancedSettingsInput(propertyName: string, propertyValue: string) { - const input = await testSubjects.find(`advancedSetting-editField-${propertyName}`); - await input.clearValue(); - await input.type(propertyValue); - await testSubjects.click(`advancedSetting-saveButton`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async setAdvancedSettingsInput(propertyName: string, propertyValue: string) { + const input = await this.testSubjects.find(`advancedSetting-editField-${propertyName}`); + await input.clearValue(); + await input.type(propertyValue); + await this.testSubjects.click(`advancedSetting-saveButton`); + await this.header.waitUntilLoadingHasFinished(); + } - async setAdvancedSettingsTextArea(propertyName: string, propertyValue: string) { - const wrapper = await testSubjects.find(`advancedSetting-editField-${propertyName}`); - const textarea = await wrapper.findByTagName('textarea'); - await textarea.focus(); - // only way to properly replace the value of the ace editor is via the JS api - await browser.execute( - (editor: string, value: string) => { - return (window as any).ace.edit(editor).setValue(value); - }, - `advancedSetting-editField-${propertyName}-editor`, - propertyValue - ); - await testSubjects.click(`advancedSetting-saveButton`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async setAdvancedSettingsTextArea(propertyName: string, propertyValue: string) { + const wrapper = await this.testSubjects.find(`advancedSetting-editField-${propertyName}`); + const textarea = await wrapper.findByTagName('textarea'); + await textarea.focus(); + // only way to properly replace the value of the ace editor is via the JS api + await this.browser.execute( + (editor: string, value: string) => { + return (window as any).ace.edit(editor).setValue(value); + }, + `advancedSetting-editField-${propertyName}-editor`, + propertyValue + ); + await this.testSubjects.click(`advancedSetting-saveButton`); + await this.header.waitUntilLoadingHasFinished(); + } - async toggleAdvancedSettingCheckbox(propertyName: string) { - await testSubjects.click(`advancedSetting-editField-${propertyName}`); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.click(`advancedSetting-saveButton`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async toggleAdvancedSettingCheckbox(propertyName: string) { + await this.testSubjects.click(`advancedSetting-editField-${propertyName}`); + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.click(`advancedSetting-saveButton`); + await this.header.waitUntilLoadingHasFinished(); + } - async navigateTo() { - await PageObjects.common.navigateToApp('settings'); - } + async navigateTo() { + await this.common.navigateToApp('settings'); + } - async getIndexPatternField() { - return await testSubjects.find('createIndexPatternNameInput'); - } + async getIndexPatternField() { + return await this.testSubjects.find('createIndexPatternNameInput'); + } - async clickTimeFieldNameField() { - return await testSubjects.click('createIndexPatternTimeFieldSelect'); - } + async clickTimeFieldNameField() { + return await this.testSubjects.click('createIndexPatternTimeFieldSelect'); + } - async getTimeFieldNameField() { - return await testSubjects.find('createIndexPatternTimeFieldSelect'); - } + async getTimeFieldNameField() { + return await this.testSubjects.find('createIndexPatternTimeFieldSelect'); + } - async selectTimeFieldOption(selection: string) { - // open dropdown - await this.clickTimeFieldNameField(); - // close dropdown, keep focus - await this.clickTimeFieldNameField(); - await PageObjects.header.waitUntilLoadingHasFinished(); - return await retry.try(async () => { - log.debug(`selectTimeFieldOption(${selection})`); - const timeFieldOption = await this.getTimeFieldOption(selection); - await timeFieldOption.click(); - const selected = await timeFieldOption.isSelected(); - if (!selected) throw new Error('option not selected: ' + selected); - }); - } + async selectTimeFieldOption(selection: string) { + // open dropdown + await this.clickTimeFieldNameField(); + // close dropdown, keep focus + await this.clickTimeFieldNameField(); + await this.header.waitUntilLoadingHasFinished(); + return await this.retry.try(async () => { + this.log.debug(`selectTimeFieldOption(${selection})`); + const timeFieldOption = await this.getTimeFieldOption(selection); + await timeFieldOption.click(); + const selected = await timeFieldOption.isSelected(); + if (!selected) throw new Error('option not selected: ' + selected); + }); + } - async getTimeFieldOption(selection: string) { - return await find.displayedByCssSelector('option[value="' + selection + '"]'); - } + async getTimeFieldOption(selection: string) { + return await this.find.displayedByCssSelector('option[value="' + selection + '"]'); + } - async getCreateIndexPatternButton() { - return await testSubjects.find('createIndexPatternButton'); - } + async getCreateIndexPatternButton() { + return await this.testSubjects.find('createIndexPatternButton'); + } - async getCreateButton() { - return await find.displayedByCssSelector('[type="submit"]'); - } + async getCreateButton() { + return await this.find.displayedByCssSelector('[type="submit"]'); + } - async clickDefaultIndexButton() { - await testSubjects.click('setDefaultIndexPatternButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async clickDefaultIndexButton() { + await this.testSubjects.click('setDefaultIndexPatternButton'); + await this.header.waitUntilLoadingHasFinished(); + } - async clickDeletePattern() { - await testSubjects.click('deleteIndexPatternButton'); - } + async clickDeletePattern() { + await this.testSubjects.click('deleteIndexPatternButton'); + } - async getIndexPageHeading() { - return await testSubjects.getVisibleText('indexPatternTitle'); - } + async getIndexPageHeading() { + return await this.testSubjects.getVisibleText('indexPatternTitle'); + } - async getConfigureHeader() { - return await find.byCssSelector('h1'); - } + async getConfigureHeader() { + return await this.find.byCssSelector('h1'); + } - async getTableHeader() { - return await find.allByCssSelector('table.euiTable thead tr th'); - } + async getTableHeader() { + return await this.find.allByCssSelector('table.euiTable thead tr th'); + } - async sortBy(columnName: string) { - const chartTypes = await find.allByCssSelector('table.euiTable thead tr th button'); + async sortBy(columnName: string) { + const chartTypes = await this.find.allByCssSelector('table.euiTable thead tr th button'); - async function getChartType(chart: Record) { - const chartString = await chart.getVisibleText(); - if (chartString === columnName) { - await chart.click(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + const getChartType = async (chart: Record) => { + const chartString = await chart.getVisibleText(); + if (chartString === columnName) { + await chart.click(); + await this.header.waitUntilLoadingHasFinished(); } + }; - const getChartTypesPromises = chartTypes.map(getChartType); - return Promise.all(getChartTypesPromises); - } + const getChartTypesPromises = chartTypes.map(getChartType); + return Promise.all(getChartTypesPromises); + } - async getTableRow(rowNumber: number, colNumber: number) { - // passing in zero-based index, but adding 1 for css 1-based indexes - return await find.byCssSelector( - 'table.euiTable tbody tr:nth-child(' + - (rowNumber + 1) + - ') td.euiTableRowCell:nth-child(' + - (colNumber + 1) + - ')' - ); - } + async getTableRow(rowNumber: number, colNumber: number) { + // passing in zero-based index, but adding 1 for css 1-based indexes + return await this.find.byCssSelector( + 'table.euiTable tbody tr:nth-child(' + + (rowNumber + 1) + + ') td.euiTableRowCell:nth-child(' + + (colNumber + 1) + + ')' + ); + } - async getFieldsTabCount() { - return retry.try(async () => { - const text = await testSubjects.getVisibleText('tab-indexedFields'); - return text.split(' ')[1].replace(/\((.*)\)/, '$1'); - }); - } + async getFieldsTabCount() { + return this.retry.try(async () => { + const text = await this.testSubjects.getVisibleText('tab-indexedFields'); + return text.split(' ')[1].replace(/\((.*)\)/, '$1'); + }); + } - async getScriptedFieldsTabCount() { - return await retry.try(async () => { - const text = await testSubjects.getVisibleText('tab-scriptedFields'); - return text.split(' ')[2].replace(/\((.*)\)/, '$1'); - }); - } + async getScriptedFieldsTabCount() { + return await this.retry.try(async () => { + const text = await this.testSubjects.getVisibleText('tab-scriptedFields'); + return text.split(' ')[2].replace(/\((.*)\)/, '$1'); + }); + } - async getFieldNames() { - const fieldNameCells = await testSubjects.findAll('editIndexPattern > indexedFieldName'); - return await mapAsync(fieldNameCells, async (cell) => { - return (await cell.getVisibleText()).trim(); - }); - } + async getFieldNames() { + const fieldNameCells = await this.testSubjects.findAll('editIndexPattern > indexedFieldName'); + return await mapAsync(fieldNameCells, async (cell) => { + return (await cell.getVisibleText()).trim(); + }); + } - async getFieldTypes() { - const fieldNameCells = await testSubjects.findAll('editIndexPattern > indexedFieldType'); - return await mapAsync(fieldNameCells, async (cell) => { - return (await cell.getVisibleText()).trim(); - }); - } + async getFieldTypes() { + const fieldNameCells = await this.testSubjects.findAll('editIndexPattern > indexedFieldType'); + return await mapAsync(fieldNameCells, async (cell) => { + return (await cell.getVisibleText()).trim(); + }); + } - async getScriptedFieldLangs() { - const fieldNameCells = await testSubjects.findAll('editIndexPattern > scriptedFieldLang'); - return await mapAsync(fieldNameCells, async (cell) => { - return (await cell.getVisibleText()).trim(); - }); - } + async getScriptedFieldLangs() { + const fieldNameCells = await this.testSubjects.findAll('editIndexPattern > scriptedFieldLang'); + return await mapAsync(fieldNameCells, async (cell) => { + return (await cell.getVisibleText()).trim(); + }); + } - async setFieldTypeFilter(type: string) { - await find.clickByCssSelector( - 'select[data-test-subj="indexedFieldTypeFilterDropdown"] > option[value="' + type + '"]' - ); - } + async setFieldTypeFilter(type: string) { + await this.find.clickByCssSelector( + 'select[data-test-subj="indexedFieldTypeFilterDropdown"] > option[value="' + type + '"]' + ); + } - async setScriptedFieldLanguageFilter(language: string) { - await find.clickByCssSelector( - 'select[data-test-subj="scriptedFieldLanguageFilterDropdown"] > option[value="' + - language + - '"]' - ); - } + async setScriptedFieldLanguageFilter(language: string) { + await this.find.clickByCssSelector( + 'select[data-test-subj="scriptedFieldLanguageFilterDropdown"] > option[value="' + + language + + '"]' + ); + } - async filterField(name: string) { - const input = await testSubjects.find('indexPatternFieldFilter'); - await input.clearValue(); - await input.type(name); - } + async filterField(name: string) { + const input = await this.testSubjects.find('indexPatternFieldFilter'); + await input.clearValue(); + await input.type(name); + } - async openControlsByName(name: string) { - await this.filterField(name); - const tableFields = await ( - await find.byCssSelector( - 'table.euiTable tbody tr.euiTableRow td.euiTableRowCell:first-child' - ) - ).getVisibleText(); - - await find.clickByCssSelector( - `table.euiTable tbody tr.euiTableRow:nth-child(${tableFields.indexOf(name) + 1}) - td:nth-last-child(2) button` - ); - } + async openControlsByName(name: string) { + await this.filterField(name); + const tableFields = await ( + await this.find.byCssSelector( + 'table.euiTable tbody tr.euiTableRow td.euiTableRowCell:first-child' + ) + ).getVisibleText(); + + await this.find.clickByCssSelector( + `table.euiTable tbody tr.euiTableRow:nth-child(${tableFields.indexOf(name) + 1}) + td:nth-last-child(2) button` + ); + } - async increasePopularity() { - await testSubjects.setValue('editorFieldCount', '1', { clearWithKeyboard: true }); - } + async increasePopularity() { + await this.testSubjects.setValue('editorFieldCount', '1', { clearWithKeyboard: true }); + } - async getPopularity() { - return await testSubjects.getAttribute('editorFieldCount', 'value'); - } + async getPopularity() { + return await this.testSubjects.getAttribute('editorFieldCount', 'value'); + } - async controlChangeCancel() { - await testSubjects.click('fieldCancelButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async controlChangeCancel() { + await this.testSubjects.click('fieldCancelButton'); + await this.header.waitUntilLoadingHasFinished(); + } - async controlChangeSave() { - await testSubjects.click('fieldSaveButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async controlChangeSave() { + await this.testSubjects.click('fieldSaveButton'); + await this.header.waitUntilLoadingHasFinished(); + } - async hasIndexPattern(name: string) { - return await find.existsByLinkText(name); - } + async hasIndexPattern(name: string) { + return await this.find.existsByLinkText(name); + } - async clickIndexPatternByName(name: string) { - const indexLink = await find.byXPath(`//a[descendant::*[text()='${name}']]`); - await indexLink.click(); - } + async clickIndexPatternByName(name: string) { + const indexLink = await this.find.byXPath(`//a[descendant::*[text()='${name}']]`); + await indexLink.click(); + } - async clickIndexPatternLogstash() { - await this.clickIndexPatternByName('logstash-*'); - } + async clickIndexPatternLogstash() { + await this.clickIndexPatternByName('logstash-*'); + } - async getIndexPatternList() { - await testSubjects.existOrFail('indexPatternTable', { timeout: 5000 }); - return await find.allByCssSelector( - '[data-test-subj="indexPatternTable"] .euiTable .euiTableRow' - ); - } + async getIndexPatternList() { + await this.testSubjects.existOrFail('indexPatternTable', { timeout: 5000 }); + return await this.find.allByCssSelector( + '[data-test-subj="indexPatternTable"] .euiTable .euiTableRow' + ); + } - async getAllIndexPatternNames() { - const indexPatterns = await this.getIndexPatternList(); - return await mapAsync(indexPatterns, async (index) => { - return await index.getVisibleText(); - }); - } + async getAllIndexPatternNames() { + const indexPatterns = await this.getIndexPatternList(); + return await mapAsync(indexPatterns, async (index) => { + return await index.getVisibleText(); + }); + } - async isIndexPatternListEmpty() { - return !(await testSubjects.exists('indexPatternTable', { timeout: 5000 })); - } + async isIndexPatternListEmpty() { + return !(await this.testSubjects.exists('indexPatternTable', { timeout: 5000 })); + } - async removeLogstashIndexPatternIfExist() { - if (!(await this.isIndexPatternListEmpty())) { - await this.clickIndexPatternLogstash(); - await this.removeIndexPattern(); - } + async removeLogstashIndexPatternIfExist() { + if (!(await this.isIndexPatternListEmpty())) { + await this.clickIndexPatternLogstash(); + await this.removeIndexPattern(); } + } - async createIndexPattern( - indexPatternName: string, - // null to bypass default value - timefield: string | null = '@timestamp', - isStandardIndexPattern = true - ) { - await retry.try(async () => { - await PageObjects.header.waitUntilLoadingHasFinished(); - await this.clickKibanaIndexPatterns(); - const exists = await this.hasIndexPattern(indexPatternName); - - if (exists) { - await this.clickIndexPatternByName(indexPatternName); - return; - } + async createIndexPattern( + indexPatternName: string, + // null to bypass default value + timefield: string | null = '@timestamp', + isStandardIndexPattern = true + ) { + await this.retry.try(async () => { + await this.header.waitUntilLoadingHasFinished(); + await this.clickKibanaIndexPatterns(); + const exists = await this.hasIndexPattern(indexPatternName); + + if (exists) { + await this.clickIndexPatternByName(indexPatternName); + return; + } - await PageObjects.header.waitUntilLoadingHasFinished(); - await this.clickAddNewIndexPatternButton(); - if (!isStandardIndexPattern) { - await this.clickCreateNewRollupButton(); - } - await PageObjects.header.waitUntilLoadingHasFinished(); - await retry.try(async () => { - await this.setIndexPatternField(indexPatternName); - }); - - const btn = await this.getCreateIndexPatternGoToStep2Button(); - await retry.waitFor(`index pattern Go To Step 2 button to be enabled`, async () => { - return await btn.isEnabled(); - }); - await btn.click(); - - await PageObjects.common.sleep(2000); - if (timefield) { - await this.selectTimeFieldOption(timefield); - } - await (await this.getCreateIndexPatternButton()).click(); + await this.header.waitUntilLoadingHasFinished(); + await this.clickAddNewIndexPatternButton(); + if (!isStandardIndexPattern) { + await this.clickCreateNewRollupButton(); + } + await this.header.waitUntilLoadingHasFinished(); + await this.retry.try(async () => { + await this.setIndexPatternField(indexPatternName); }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await retry.try(async () => { - const currentUrl = await browser.getCurrentUrl(); - log.info('currentUrl', currentUrl); - if (!currentUrl.match(/indexPatterns\/.+\?/)) { - throw new Error('Index pattern not created'); - } else { - log.debug('Index pattern created: ' + currentUrl); - } + + const btn = await this.getCreateIndexPatternGoToStep2Button(); + await this.retry.waitFor(`index pattern Go To Step 2 button to be enabled`, async () => { + return await btn.isEnabled(); }); + await btn.click(); - return await this.getIndexPatternIdFromUrl(); - } + await this.common.sleep(2000); + if (timefield) { + await this.selectTimeFieldOption(timefield); + } + await (await this.getCreateIndexPatternButton()).click(); + }); + await this.header.waitUntilLoadingHasFinished(); + await this.retry.try(async () => { + const currentUrl = await this.browser.getCurrentUrl(); + this.log.info('currentUrl', currentUrl); + if (!currentUrl.match(/indexPatterns\/.+\?/)) { + throw new Error('Index pattern not created'); + } else { + this.log.debug('Index pattern created: ' + currentUrl); + } + }); - async clickAddNewIndexPatternButton() { - await PageObjects.common.scrollKibanaBodyTop(); - await testSubjects.click('createIndexPatternButton'); - } + return await this.getIndexPatternIdFromUrl(); + } - async clickCreateNewRollupButton() { - await testSubjects.click('createRollupIndexPatternButton'); - } + async clickAddNewIndexPatternButton() { + await this.common.scrollKibanaBodyTop(); + await this.testSubjects.click('createIndexPatternButton'); + } - async getIndexPatternIdFromUrl() { - const currentUrl = await browser.getCurrentUrl(); - const indexPatternId = currentUrl.match(/.*\/(.*)/)![1]; + async clickCreateNewRollupButton() { + await this.testSubjects.click('createRollupIndexPatternButton'); + } - log.debug('index pattern ID: ', indexPatternId); + async getIndexPatternIdFromUrl() { + const currentUrl = await this.browser.getCurrentUrl(); + const indexPatternId = currentUrl.match(/.*\/(.*)/)![1]; - return indexPatternId; - } + this.log.debug('index pattern ID: ', indexPatternId); - async setIndexPatternField(indexPatternName = 'logstash-*') { - log.debug(`setIndexPatternField(${indexPatternName})`); - const field = await this.getIndexPatternField(); - await field.clearValue(); - if ( - indexPatternName.charAt(0) === '*' && - indexPatternName.charAt(indexPatternName.length - 1) === '*' - ) { - // this is a special case when the index pattern name starts with '*' - // like '*:makelogs-*' where the UI will not append * - await field.type(indexPatternName, { charByChar: true }); - } else if (indexPatternName.charAt(indexPatternName.length - 1) === '*') { - // the common case where the UI will append '*' automatically so we won't type it - const tempName = indexPatternName.slice(0, -1); - await field.type(tempName, { charByChar: true }); - } else { - // case where we don't want the * appended so we'll remove it if it was added - await field.type(indexPatternName, { charByChar: true }); - const tempName = await field.getAttribute('value'); - if (tempName.length > indexPatternName.length) { - await field.type(browser.keys.DELETE, { charByChar: true }); - } + return indexPatternId; + } + + async setIndexPatternField(indexPatternName = 'logstash-*') { + this.log.debug(`setIndexPatternField(${indexPatternName})`); + const field = await this.getIndexPatternField(); + await field.clearValue(); + if ( + indexPatternName.charAt(0) === '*' && + indexPatternName.charAt(indexPatternName.length - 1) === '*' + ) { + // this is a special case when the index pattern name starts with '*' + // like '*:makelogs-*' where the UI will not append * + await field.type(indexPatternName, { charByChar: true }); + } else if (indexPatternName.charAt(indexPatternName.length - 1) === '*') { + // the common case where the UI will append '*' automatically so we won't type it + const tempName = indexPatternName.slice(0, -1); + await field.type(tempName, { charByChar: true }); + } else { + // case where we don't want the * appended so we'll remove it if it was added + await field.type(indexPatternName, { charByChar: true }); + const tempName = await field.getAttribute('value'); + if (tempName.length > indexPatternName.length) { + await field.type(this.browser.keys.DELETE, { charByChar: true }); } - const currentName = await field.getAttribute('value'); - log.debug(`setIndexPatternField set to ${currentName}`); - expect(currentName).to.eql(indexPatternName); } + const currentName = await field.getAttribute('value'); + this.log.debug(`setIndexPatternField set to ${currentName}`); + expect(currentName).to.eql(indexPatternName); + } - async getCreateIndexPatternGoToStep2Button() { - return await testSubjects.find('createIndexPatternGoToStep2Button'); - } + async getCreateIndexPatternGoToStep2Button() { + return await this.testSubjects.find('createIndexPatternGoToStep2Button'); + } - async removeIndexPattern() { - let alertText; - await retry.try(async () => { - log.debug('click delete index pattern button'); - await this.clickDeletePattern(); - }); - await retry.try(async () => { - log.debug('getAlertText'); - alertText = await testSubjects.getVisibleText('confirmModalTitleText'); - }); - await retry.try(async () => { - log.debug('acceptConfirmation'); - await testSubjects.click('confirmModalConfirmButton'); - }); - await retry.try(async () => { - const currentUrl = await browser.getCurrentUrl(); - if (currentUrl.match(/index_patterns\/.+\?/)) { - throw new Error('Index pattern not removed'); - } - }); - return alertText; - } + async removeIndexPattern() { + let alertText; + await this.retry.try(async () => { + this.log.debug('click delete index pattern button'); + await this.clickDeletePattern(); + }); + await this.retry.try(async () => { + this.log.debug('getAlertText'); + alertText = await this.testSubjects.getVisibleText('confirmModalTitleText'); + }); + await this.retry.try(async () => { + this.log.debug('acceptConfirmation'); + await this.testSubjects.click('confirmModalConfirmButton'); + }); + await this.retry.try(async () => { + const currentUrl = await this.browser.getCurrentUrl(); + if (currentUrl.match(/index_patterns\/.+\?/)) { + throw new Error('Index pattern not removed'); + } + }); + return alertText; + } - async clickFieldsTab() { - log.debug('click Fields tab'); - await testSubjects.click('tab-indexedFields'); - } + async clickFieldsTab() { + this.log.debug('click Fields tab'); + await this.testSubjects.click('tab-indexedFields'); + } - async clickScriptedFieldsTab() { - log.debug('click Scripted Fields tab'); - await testSubjects.click('tab-scriptedFields'); - } + async clickScriptedFieldsTab() { + this.log.debug('click Scripted Fields tab'); + await this.testSubjects.click('tab-scriptedFields'); + } - async clickSourceFiltersTab() { - log.debug('click Source Filters tab'); - await testSubjects.click('tab-sourceFilters'); - } + async clickSourceFiltersTab() { + this.log.debug('click Source Filters tab'); + await this.testSubjects.click('tab-sourceFilters'); + } - async editScriptedField(name: string) { - await this.filterField(name); - await find.clickByCssSelector('.euiTableRowCell--hasActions button:first-child'); - } + async editScriptedField(name: string) { + await this.filterField(name); + await this.find.clickByCssSelector('.euiTableRowCell--hasActions button:first-child'); + } - async addScriptedField( - name: string, - language: string, - type: string, - format: Record, - popularity: string, - script: string - ) { - await this.clickAddScriptedField(); - await this.setScriptedFieldName(name); - if (language) await this.setScriptedFieldLanguage(language); - if (type) await this.setScriptedFieldType(type); - if (format) { - await this.setFieldFormat(format.format); - // null means leave - default - which has no other settings - // Url adds Type, Url Template, and Label Template - // Date adds moment.js format pattern (Default: "MMMM Do YYYY, HH:mm:ss.SSS") - // String adds Transform - switch (format.format) { - case 'url': - await this.setScriptedFieldUrlType(format.type); - await this.setScriptedFieldUrlTemplate(format.template); - await this.setScriptedFieldUrlLabelTemplate(format.labelTemplate); - break; - case 'date': - await this.setScriptedFieldDatePattern(format.datePattern); - break; - case 'string': - await this.setScriptedFieldStringTransform(format.stringTransform); - break; - } + async addScriptedField( + name: string, + language: string, + type: string, + format: Record, + popularity: string, + script: string + ) { + await this.clickAddScriptedField(); + await this.setScriptedFieldName(name); + if (language) await this.setScriptedFieldLanguage(language); + if (type) await this.setScriptedFieldType(type); + if (format) { + await this.setFieldFormat(format.format); + // null means leave - default - which has no other settings + // Url adds Type, Url Template, and Label Template + // Date adds moment.js format pattern (Default: "MMMM Do YYYY, HH:mm:ss.SSS") + // String adds Transform + switch (format.format) { + case 'url': + await this.setScriptedFieldUrlType(format.type); + await this.setScriptedFieldUrlTemplate(format.template); + await this.setScriptedFieldUrlLabelTemplate(format.labelTemplate); + break; + case 'date': + await this.setScriptedFieldDatePattern(format.datePattern); + break; + case 'string': + await this.setScriptedFieldStringTransform(format.stringTransform); + break; } - if (popularity) await this.setScriptedFieldPopularity(popularity); - await this.setScriptedFieldScript(script); - await this.clickSaveScriptedField(); } + if (popularity) await this.setScriptedFieldPopularity(popularity); + await this.setScriptedFieldScript(script); + await this.clickSaveScriptedField(); + } - async addRuntimeField(name: string, type: string, script: string) { - await this.clickAddField(); - await this.setFieldName(name); - await this.setFieldType(type); - if (script) { - await this.setFieldScript(script); - } - await this.clickSaveField(); - await this.closeIndexPatternFieldEditor(); + async addRuntimeField(name: string, type: string, script: string) { + await this.clickAddField(); + await this.setFieldName(name); + await this.setFieldType(type); + if (script) { + await this.setFieldScript(script); } + await this.clickSaveField(); + await this.closeIndexPatternFieldEditor(); + } - public async confirmSave() { - await testSubjects.setValue('saveModalConfirmText', 'change'); - await testSubjects.click('confirmModalConfirmButton'); - } + public async confirmSave() { + await this.testSubjects.setValue('saveModalConfirmText', 'change'); + await this.testSubjects.click('confirmModalConfirmButton'); + } - public async confirmDelete() { - await testSubjects.setValue('deleteModalConfirmText', 'remove'); - await testSubjects.click('confirmModalConfirmButton'); - } + public async confirmDelete() { + await this.testSubjects.setValue('deleteModalConfirmText', 'remove'); + await this.testSubjects.click('confirmModalConfirmButton'); + } - async closeIndexPatternFieldEditor() { - await retry.waitFor('field editor flyout to close', async () => { - return !(await testSubjects.exists('euiFlyoutCloseButton')); - }); - } + async closeIndexPatternFieldEditor() { + await this.retry.waitFor('field editor flyout to close', async () => { + return !(await this.testSubjects.exists('euiFlyoutCloseButton')); + }); + } - async clickAddField() { - log.debug('click Add Field'); - await testSubjects.click('addField'); - } + async clickAddField() { + this.log.debug('click Add Field'); + await this.testSubjects.click('addField'); + } - async clickSaveField() { - log.debug('click Save'); - await testSubjects.click('fieldSaveButton'); - } + async clickSaveField() { + this.log.debug('click Save'); + await this.testSubjects.click('fieldSaveButton'); + } - async setFieldName(name: string) { - log.debug('set field name = ' + name); - await testSubjects.setValue('nameField', name); - } + async setFieldName(name: string) { + this.log.debug('set field name = ' + name); + await this.testSubjects.setValue('nameField', name); + } - async setFieldType(type: string) { - log.debug('set type = ' + type); - await testSubjects.setValue('typeField', type); - } + async setFieldType(type: string) { + this.log.debug('set type = ' + type); + await this.testSubjects.setValue('typeField', type); + } - async setFieldScript(script: string) { - log.debug('set script = ' + script); - const formatRow = await testSubjects.find('valueRow'); - const formatRowToggle = ( - await formatRow.findAllByCssSelector('[data-test-subj="toggle"]') - )[0]; - - await formatRowToggle.click(); - const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; - retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); - const monacoTextArea = await getMonacoTextArea(); - await monacoTextArea.focus(); - browser.pressKeys(script); - } + async setFieldScript(script: string) { + this.log.debug('set script = ' + script); + const formatRow = await this.testSubjects.find('valueRow'); + const formatRowToggle = (await formatRow.findAllByCssSelector('[data-test-subj="toggle"]'))[0]; + + await formatRowToggle.click(); + const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; + this.retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); + const monacoTextArea = await getMonacoTextArea(); + await monacoTextArea.focus(); + this.browser.pressKeys(script); + } - async changeFieldScript(script: string) { - log.debug('set script = ' + script); - const formatRow = await testSubjects.find('valueRow'); - const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; - retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); - const monacoTextArea = await getMonacoTextArea(); - await monacoTextArea.focus(); - browser.pressKeys(browser.keys.DELETE.repeat(30)); - browser.pressKeys(script); - } + async changeFieldScript(script: string) { + this.log.debug('set script = ' + script); + const formatRow = await this.testSubjects.find('valueRow'); + const getMonacoTextArea = async () => (await formatRow.findAllByCssSelector('textarea'))[0]; + this.retry.waitFor('monaco editor is ready', async () => !!(await getMonacoTextArea())); + const monacoTextArea = await getMonacoTextArea(); + await monacoTextArea.focus(); + this.browser.pressKeys(this.browser.keys.DELETE.repeat(30)); + this.browser.pressKeys(script); + } - async clickAddScriptedField() { - log.debug('click Add Scripted Field'); - await testSubjects.click('addScriptedFieldLink'); - } + async clickAddScriptedField() { + this.log.debug('click Add Scripted Field'); + await this.testSubjects.click('addScriptedFieldLink'); + } - async clickSaveScriptedField() { - log.debug('click Save Scripted Field'); - await testSubjects.click('fieldSaveButton'); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + async clickSaveScriptedField() { + this.log.debug('click Save Scripted Field'); + await this.testSubjects.click('fieldSaveButton'); + await this.header.waitUntilLoadingHasFinished(); + } - async setScriptedFieldName(name: string) { - log.debug('set scripted field name = ' + name); - await testSubjects.setValue('editorFieldName', name); - } + async setScriptedFieldName(name: string) { + this.log.debug('set scripted field name = ' + name); + await this.testSubjects.setValue('editorFieldName', name); + } - async setScriptedFieldLanguage(language: string) { - log.debug('set scripted field language = ' + language); - await find.clickByCssSelector( - 'select[data-test-subj="editorFieldLang"] > option[value="' + language + '"]' - ); - } + async setScriptedFieldLanguage(language: string) { + this.log.debug('set scripted field language = ' + language); + await this.find.clickByCssSelector( + 'select[data-test-subj="editorFieldLang"] > option[value="' + language + '"]' + ); + } - async setScriptedFieldType(type: string) { - log.debug('set scripted field type = ' + type); - await find.clickByCssSelector( - 'select[data-test-subj="editorFieldType"] > option[value="' + type + '"]' - ); - } + async setScriptedFieldType(type: string) { + this.log.debug('set scripted field type = ' + type); + await this.find.clickByCssSelector( + 'select[data-test-subj="editorFieldType"] > option[value="' + type + '"]' + ); + } - async setFieldFormat(format: string) { - log.debug('set scripted field format = ' + format); - await find.clickByCssSelector( - 'select[data-test-subj="editorSelectedFormatId"] > option[value="' + format + '"]' - ); - } + async setFieldFormat(format: string) { + this.log.debug('set scripted field format = ' + format); + await this.find.clickByCssSelector( + 'select[data-test-subj="editorSelectedFormatId"] > option[value="' + format + '"]' + ); + } - async setScriptedFieldUrlType(type: string) { - log.debug('set scripted field Url type = ' + type); - await find.clickByCssSelector( - 'select[data-test-subj="urlEditorType"] > option[value="' + type + '"]' - ); - } + async setScriptedFieldUrlType(type: string) { + this.log.debug('set scripted field Url type = ' + type); + await this.find.clickByCssSelector( + 'select[data-test-subj="urlEditorType"] > option[value="' + type + '"]' + ); + } - async setScriptedFieldUrlTemplate(template: string) { - log.debug('set scripted field Url Template = ' + template); - const urlTemplateField = await find.byCssSelector( - 'input[data-test-subj="urlEditorUrlTemplate"]' - ); - await urlTemplateField.type(template); - } + async setScriptedFieldUrlTemplate(template: string) { + this.log.debug('set scripted field Url Template = ' + template); + const urlTemplateField = await this.find.byCssSelector( + 'input[data-test-subj="urlEditorUrlTemplate"]' + ); + await urlTemplateField.type(template); + } - async setScriptedFieldUrlLabelTemplate(labelTemplate: string) { - log.debug('set scripted field Url Label Template = ' + labelTemplate); - const urlEditorLabelTemplate = await find.byCssSelector( - 'input[data-test-subj="urlEditorLabelTemplate"]' - ); - await urlEditorLabelTemplate.type(labelTemplate); - } + async setScriptedFieldUrlLabelTemplate(labelTemplate: string) { + this.log.debug('set scripted field Url Label Template = ' + labelTemplate); + const urlEditorLabelTemplate = await this.find.byCssSelector( + 'input[data-test-subj="urlEditorLabelTemplate"]' + ); + await urlEditorLabelTemplate.type(labelTemplate); + } - async setScriptedFieldDatePattern(datePattern: string) { - log.debug('set scripted field Date Pattern = ' + datePattern); - const datePatternField = await find.byCssSelector( - 'input[data-test-subj="dateEditorPattern"]' - ); - // clearValue does not work here - // Send Backspace event for each char in value string to clear field - await datePatternField.clearValueWithKeyboard({ charByChar: true }); - await datePatternField.type(datePattern); - } + async setScriptedFieldDatePattern(datePattern: string) { + this.log.debug('set scripted field Date Pattern = ' + datePattern); + const datePatternField = await this.find.byCssSelector( + 'input[data-test-subj="dateEditorPattern"]' + ); + // clearValue does not work here + // Send Backspace event for each char in value string to clear field + await datePatternField.clearValueWithKeyboard({ charByChar: true }); + await datePatternField.type(datePattern); + } - async setScriptedFieldStringTransform(stringTransform: string) { - log.debug('set scripted field string Transform = ' + stringTransform); - await find.clickByCssSelector( - 'select[data-test-subj="stringEditorTransform"] > option[value="' + stringTransform + '"]' - ); - } + async setScriptedFieldStringTransform(stringTransform: string) { + this.log.debug('set scripted field string Transform = ' + stringTransform); + await this.find.clickByCssSelector( + 'select[data-test-subj="stringEditorTransform"] > option[value="' + stringTransform + '"]' + ); + } - async setScriptedFieldPopularity(popularity: string) { - log.debug('set scripted field popularity = ' + popularity); - await testSubjects.setValue('editorFieldCount', popularity); - } + async setScriptedFieldPopularity(popularity: string) { + this.log.debug('set scripted field popularity = ' + popularity); + await this.testSubjects.setValue('editorFieldCount', popularity); + } - async setScriptedFieldScript(script: string) { - log.debug('set scripted field script = ' + script); - const aceEditorCssSelector = '[data-test-subj="editorFieldScript"] .ace_editor'; - const editor = await find.byCssSelector(aceEditorCssSelector); - await editor.click(); - const existingText = await editor.getVisibleText(); - for (let i = 0; i < existingText.length; i++) { - await browser.pressKeys(browser.keys.BACK_SPACE); - } - await browser.pressKeys(...script.split('')); + async setScriptedFieldScript(script: string) { + this.log.debug('set scripted field script = ' + script); + const aceEditorCssSelector = '[data-test-subj="editorFieldScript"] .ace_editor'; + const editor = await this.find.byCssSelector(aceEditorCssSelector); + await editor.click(); + const existingText = await editor.getVisibleText(); + for (let i = 0; i < existingText.length; i++) { + await this.browser.pressKeys(this.browser.keys.BACK_SPACE); } + await this.browser.pressKeys(...script.split('')); + } - async openScriptedFieldHelp(activeTab: string) { - log.debug('open Scripted Fields help'); - let isOpen = await testSubjects.exists('scriptedFieldsHelpFlyout'); - if (!isOpen) { - await retry.try(async () => { - await testSubjects.click('scriptedFieldsHelpLink'); - isOpen = await testSubjects.exists('scriptedFieldsHelpFlyout'); - if (!isOpen) { - throw new Error('Failed to open scripted fields help'); - } - }); - } - - if (activeTab) { - await testSubjects.click(activeTab); - } + async openScriptedFieldHelp(activeTab: string) { + this.log.debug('open Scripted Fields help'); + let isOpen = await this.testSubjects.exists('scriptedFieldsHelpFlyout'); + if (!isOpen) { + await this.retry.try(async () => { + await this.testSubjects.click('scriptedFieldsHelpLink'); + isOpen = await this.testSubjects.exists('scriptedFieldsHelpFlyout'); + if (!isOpen) { + throw new Error('Failed to open scripted fields help'); + } + }); } - async closeScriptedFieldHelp() { - await flyout.ensureClosed('scriptedFieldsHelpFlyout'); + if (activeTab) { + await this.testSubjects.click(activeTab); } + } - async executeScriptedField(script: string, additionalField: string) { - log.debug('execute Scripted Fields help'); - await this.closeScriptedFieldHelp(); // ensure script help is closed so script input is not blocked - await this.setScriptedFieldScript(script); - await this.openScriptedFieldHelp('testTab'); - if (additionalField) { - await comboBox.set('additionalFieldsSelect', additionalField); - await testSubjects.find('scriptedFieldPreview'); - await testSubjects.click('runScriptButton'); - await testSubjects.waitForDeleted('.euiLoadingSpinner'); - } - let scriptResults; - await retry.try(async () => { - scriptResults = await testSubjects.getVisibleText('scriptedFieldPreview'); - }); - return scriptResults; - } + async closeScriptedFieldHelp() { + await this.flyout.ensureClosed('scriptedFieldsHelpFlyout'); + } - async clickEditFieldFormat() { - await testSubjects.click('editFieldFormat'); - } + async executeScriptedField(script: string, additionalField: string) { + this.log.debug('execute Scripted Fields help'); + await this.closeScriptedFieldHelp(); // ensure script help is closed so script input is not blocked + await this.setScriptedFieldScript(script); + await this.openScriptedFieldHelp('testTab'); + if (additionalField) { + await this.comboBox.set('additionalFieldsSelect', additionalField); + await this.testSubjects.find('scriptedFieldPreview'); + await this.testSubjects.click('runScriptButton'); + await this.testSubjects.waitForDeleted('.euiLoadingSpinner'); + } + let scriptResults; + await this.retry.try(async () => { + scriptResults = await this.testSubjects.getVisibleText('scriptedFieldPreview'); + }); + return scriptResults; + } - async associateIndexPattern(oldIndexPatternId: string, newIndexPatternTitle: string) { - await find.clickByCssSelector( - `select[data-test-subj="managementChangeIndexSelection-${oldIndexPatternId}"] > - [data-test-subj="indexPatternOption-${newIndexPatternTitle}"]` - ); - } + async clickEditFieldFormat() { + await this.testSubjects.click('editFieldFormat'); + } - async clickChangeIndexConfirmButton() { - await testSubjects.click('changeIndexConfirmButton'); - } + async associateIndexPattern(oldIndexPatternId: string, newIndexPatternTitle: string) { + await this.find.clickByCssSelector( + `select[data-test-subj="managementChangeIndexSelection-${oldIndexPatternId}"] > + [data-test-subj="indexPatternOption-${newIndexPatternTitle}"]` + ); } - return new SettingsPage(); + async clickChangeIndexConfirmButton() { + await this.testSubjects.click('changeIndexConfirmButton'); + } } diff --git a/test/functional/page_objects/share_page.ts b/test/functional/page_objects/share_page.ts index aa58341600599b..ce1dc4c45e21fb 100644 --- a/test/functional/page_objects/share_page.ts +++ b/test/functional/page_objects/share_page.ts @@ -6,76 +6,72 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function SharePageProvider({ getService }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const find = getService('find'); - const log = getService('log'); +export class SharePageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); - class SharePage { - async isShareMenuOpen() { - return await testSubjects.exists('shareContextMenu'); - } - - async clickShareTopNavButton() { - return testSubjects.click('shareTopNavButton'); - } + async isShareMenuOpen() { + return await this.testSubjects.exists('shareContextMenu'); + } - async openShareMenuItem(itemTitle: string) { - log.debug(`openShareMenuItem title:${itemTitle}`); - const isShareMenuOpen = await this.isShareMenuOpen(); - if (!isShareMenuOpen) { - await this.clickShareTopNavButton(); - } else { - // there is no easy way to ensure the menu is at the top level - // so just close the existing menu - await this.clickShareTopNavButton(); - // and then re-open the menu - await this.clickShareTopNavButton(); - } - const menuPanel = await find.byCssSelector('div.euiContextMenuPanel'); - await testSubjects.click(`sharePanel-${itemTitle.replace(' ', '')}`); - await testSubjects.waitForDeleted(menuPanel); - } + async clickShareTopNavButton() { + return this.testSubjects.click('shareTopNavButton'); + } - /** - * if there are more entries in the share menu, the permalinks entry has to be clicked first - * else the selection isn't displayed. this happens if you're testing against an instance - * with xpack features enabled, where there's also a csv sharing option - * in a pure OSS environment, the permalinks sharing panel is displayed initially - */ - async openPermaLinks() { - if (await testSubjects.exists('sharePanel-Permalinks')) { - await testSubjects.click(`sharePanel-Permalinks`); - } + async openShareMenuItem(itemTitle: string) { + this.log.debug(`openShareMenuItem title:${itemTitle}`); + const isShareMenuOpen = await this.isShareMenuOpen(); + if (!isShareMenuOpen) { + await this.clickShareTopNavButton(); + } else { + // there is no easy way to ensure the menu is at the top level + // so just close the existing menu + await this.clickShareTopNavButton(); + // and then re-open the menu + await this.clickShareTopNavButton(); } + const menuPanel = await this.find.byCssSelector('div.euiContextMenuPanel'); + await this.testSubjects.click(`sharePanel-${itemTitle.replace(' ', '')}`); + await this.testSubjects.waitForDeleted(menuPanel); + } - async getSharedUrl() { - await this.openPermaLinks(); - return await testSubjects.getAttribute('copyShareUrlButton', 'data-share-url'); + /** + * if there are more entries in the share menu, the permalinks entry has to be clicked first + * else the selection isn't displayed. this happens if you're testing against an instance + * with xpack features enabled, where there's also a csv sharing option + * in a pure OSS environment, the permalinks sharing panel is displayed initially + */ + async openPermaLinks() { + if (await this.testSubjects.exists('sharePanel-Permalinks')) { + await this.testSubjects.click(`sharePanel-Permalinks`); } + } - async createShortUrlExistOrFail() { - await testSubjects.existOrFail('createShortUrl'); - } + async getSharedUrl() { + await this.openPermaLinks(); + return await this.testSubjects.getAttribute('copyShareUrlButton', 'data-share-url'); + } - async createShortUrlMissingOrFail() { - await testSubjects.missingOrFail('createShortUrl'); - } + async createShortUrlExistOrFail() { + await this.testSubjects.existOrFail('createShortUrl'); + } - async checkShortenUrl() { - await this.openPermaLinks(); - const shareForm = await testSubjects.find('shareUrlForm'); - await testSubjects.setCheckbox('useShortUrl', 'check'); - await shareForm.waitForDeletedByCssSelector('.euiLoadingSpinner'); - } + async createShortUrlMissingOrFail() { + await this.testSubjects.missingOrFail('createShortUrl'); + } - async exportAsSavedObject() { - await this.openPermaLinks(); - return await testSubjects.click('exportAsSavedObject'); - } + async checkShortenUrl() { + await this.openPermaLinks(); + const shareForm = await this.testSubjects.find('shareUrlForm'); + await this.testSubjects.setCheckbox('useShortUrl', 'check'); + await shareForm.waitForDeletedByCssSelector('.euiLoadingSpinner'); } - return new SharePage(); + async exportAsSavedObject() { + await this.openPermaLinks(); + return await this.testSubjects.click('exportAsSavedObject'); + } } diff --git a/test/functional/page_objects/tag_cloud_page.ts b/test/functional/page_objects/tag_cloud_page.ts index fa977618e64d77..61e844c813df8c 100644 --- a/test/functional/page_objects/tag_cloud_page.ts +++ b/test/functional/page_objects/tag_cloud_page.ts @@ -6,36 +6,33 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; -export function TagCloudPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const find = getService('find'); - const testSubjects = getService('testSubjects'); - const { header, visChart } = getPageObjects(['header', 'visChart']); +export class TagCloudPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly header = this.ctx.getPageObject('header'); + private readonly visChart = this.ctx.getPageObject('visChart'); - class TagCloudPage { - public async selectTagCloudTag(tagDisplayText: string) { - await testSubjects.click(tagDisplayText); - await header.waitUntilLoadingHasFinished(); - } + public async selectTagCloudTag(tagDisplayText: string) { + await this.testSubjects.click(tagDisplayText); + await this.header.waitUntilLoadingHasFinished(); + } - public async getTextTag() { - await visChart.waitForVisualization(); - const elements = await find.allByCssSelector('text'); - return await Promise.all(elements.map(async (element) => await element.getVisibleText())); - } + public async getTextTag() { + await this.visChart.waitForVisualization(); + const elements = await this.find.allByCssSelector('text'); + return await Promise.all(elements.map(async (element) => await element.getVisibleText())); + } - public async getTextSizes() { - const tags = await find.allByCssSelector('text'); - async function returnTagSize(tag: WebElementWrapper) { - const style = await tag.getAttribute('style'); - const fontSize = style.match(/font-size: ([^;]*);/); - return fontSize ? fontSize[1] : ''; - } - return await Promise.all(tags.map(returnTagSize)); + public async getTextSizes() { + const tags = await this.find.allByCssSelector('text'); + async function returnTagSize(tag: WebElementWrapper) { + const style = await tag.getAttribute('style'); + const fontSize = style.match(/font-size: ([^;]*);/); + return fontSize ? fontSize[1] : ''; } + return await Promise.all(tags.map(returnTagSize)); } - - return new TagCloudPage(); } diff --git a/test/functional/page_objects/tile_map_page.ts b/test/functional/page_objects/tile_map_page.ts index 6008d7434bf1d3..079ca919543e22 100644 --- a/test/functional/page_objects/tile_map_page.ts +++ b/test/functional/page_objects/tile_map_page.ts @@ -6,92 +6,88 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; - -export function TileMapPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const find = getService('find'); - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - const log = getService('log'); - const inspector = getService('inspector'); - const monacoEditor = getService('monacoEditor'); - const { header } = getPageObjects(['header']); - - class TileMapPage { - public async getZoomSelectors(zoomSelector: string) { - return await find.allByCssSelector(zoomSelector); - } - - public async clickMapButton(zoomSelector: string, waitForLoading?: boolean) { - await retry.try(async () => { - const zooms = await this.getZoomSelectors(zoomSelector); - for (let i = 0; i < zooms.length; i++) { - await zooms[i].click(); - } - if (waitForLoading) { - await header.waitUntilLoadingHasFinished(); - } - }); - } +import { FtrService } from '../ftr_provider_context'; + +export class TileMapPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); + private readonly log = this.ctx.getService('log'); + private readonly inspector = this.ctx.getService('inspector'); + private readonly monacoEditor = this.ctx.getService('monacoEditor'); + private readonly header = this.ctx.getPageObject('header'); + + public async getZoomSelectors(zoomSelector: string) { + return await this.find.allByCssSelector(zoomSelector); + } - public async getVisualizationRequest() { - log.debug('getVisualizationRequest'); - await inspector.open(); - await testSubjects.click('inspectorViewChooser'); - await testSubjects.click('inspectorViewChooserRequests'); - await testSubjects.click('inspectorRequestDetailRequest'); - await find.byCssSelector('.react-monaco-editor-container'); + public async clickMapButton(zoomSelector: string, waitForLoading?: boolean) { + await this.retry.try(async () => { + const zooms = await this.getZoomSelectors(zoomSelector); + for (let i = 0; i < zooms.length; i++) { + await zooms[i].click(); + } + if (waitForLoading) { + await this.header.waitUntilLoadingHasFinished(); + } + }); + } - return await monacoEditor.getCodeEditorValue(1); - } + public async getVisualizationRequest() { + this.log.debug('getVisualizationRequest'); + await this.inspector.open(); + await this.testSubjects.click('inspectorViewChooser'); + await this.testSubjects.click('inspectorViewChooserRequests'); + await this.testSubjects.click('inspectorRequestDetailRequest'); + await this.find.byCssSelector('.react-monaco-editor-container'); - public async getMapBounds(): Promise { - const request = await this.getVisualizationRequest(); - const requestObject = JSON.parse(request); + return await this.monacoEditor.getCodeEditorValue(1); + } - return requestObject.aggs.filter_agg.filter.geo_bounding_box['geo.coordinates']; - } + public async getMapBounds(): Promise { + const request = await this.getVisualizationRequest(); + const requestObject = JSON.parse(request); - public async clickMapZoomIn(waitForLoading = true) { - await this.clickMapButton('a.leaflet-control-zoom-in', waitForLoading); - } + return requestObject.aggs.filter_agg.filter.geo_bounding_box['geo.coordinates']; + } - public async clickMapZoomOut(waitForLoading = true) { - await this.clickMapButton('a.leaflet-control-zoom-out', waitForLoading); - } + public async clickMapZoomIn(waitForLoading = true) { + await this.clickMapButton('a.leaflet-control-zoom-in', waitForLoading); + } - public async getMapZoomEnabled(zoomSelector: string): Promise { - const zooms = await this.getZoomSelectors(zoomSelector); - const classAttributes = await Promise.all( - zooms.map(async (zoom) => await zoom.getAttribute('class')) - ); - return !classAttributes.join('').includes('leaflet-disabled'); - } + public async clickMapZoomOut(waitForLoading = true) { + await this.clickMapButton('a.leaflet-control-zoom-out', waitForLoading); + } - public async zoomAllTheWayOut(): Promise { - // we can tell we're at level 1 because zoom out is disabled - return await retry.try(async () => { - await this.clickMapZoomOut(); - const enabled = await this.getMapZoomOutEnabled(); - // should be able to zoom more as current config has 0 as min level. - if (enabled) { - throw new Error('Not fully zoomed out yet'); - } - }); - } + public async getMapZoomEnabled(zoomSelector: string): Promise { + const zooms = await this.getZoomSelectors(zoomSelector); + const classAttributes = await Promise.all( + zooms.map(async (zoom) => await zoom.getAttribute('class')) + ); + return !classAttributes.join('').includes('leaflet-disabled'); + } - public async getMapZoomInEnabled() { - return await this.getMapZoomEnabled('a.leaflet-control-zoom-in'); - } + public async zoomAllTheWayOut(): Promise { + // we can tell we're at level 1 because zoom out is disabled + return await this.retry.try(async () => { + await this.clickMapZoomOut(); + const enabled = await this.getMapZoomOutEnabled(); + // should be able to zoom more as current config has 0 as min level. + if (enabled) { + throw new Error('Not fully zoomed out yet'); + } + }); + } - public async getMapZoomOutEnabled() { - return await this.getMapZoomEnabled('a.leaflet-control-zoom-out'); - } + public async getMapZoomInEnabled() { + return await this.getMapZoomEnabled('a.leaflet-control-zoom-in'); + } - public async clickMapFitDataBounds() { - return await this.clickMapButton('a.fa-crop'); - } + public async getMapZoomOutEnabled() { + return await this.getMapZoomEnabled('a.leaflet-control-zoom-out'); } - return new TileMapPage(); + public async clickMapFitDataBounds() { + return await this.clickMapButton('a.fa-crop'); + } } diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index 4d0930c3ff932d..e8f6afc365f5d1 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -7,7 +7,7 @@ */ import moment from 'moment'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; export type CommonlyUsed = @@ -22,275 +22,270 @@ export type CommonlyUsed = | 'Last_90 days' | 'Last_1 year'; -export function TimePickerProvider({ getService, getPageObjects }: FtrProviderContext) { - const log = getService('log'); - const find = getService('find'); - const browser = getService('browser'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const { header } = getPageObjects(['header']); - const kibanaServer = getService('kibanaServer'); - const menuToggle = getService('menuToggle'); - - const quickSelectTimeMenuToggle = menuToggle.create({ +export class TimePickerPageObject extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly find = this.ctx.getService('find'); + private readonly browser = this.ctx.getService('browser'); + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly header = this.ctx.getPageObject('header'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); + + private readonly quickSelectTimeMenuToggle = this.ctx.getService('menuToggle').create({ name: 'QuickSelectTime Menu', menuTestSubject: 'superDatePickerQuickMenu', toggleButtonTestSubject: 'superDatePickerToggleQuickMenuButton', }); - class TimePicker { - defaultStartTime = 'Sep 19, 2015 @ 06:31:44.000'; - defaultEndTime = 'Sep 23, 2015 @ 18:31:44.000'; - defaultStartTimeUTC = '2015-09-18T06:31:44.000Z'; - defaultEndTimeUTC = '2015-09-23T18:31:44.000Z'; - - async setDefaultAbsoluteRange() { - await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime); - } + public readonly defaultStartTime = 'Sep 19, 2015 @ 06:31:44.000'; + public readonly defaultEndTime = 'Sep 23, 2015 @ 18:31:44.000'; + public readonly defaultStartTimeUTC = '2015-09-18T06:31:44.000Z'; + public readonly defaultEndTimeUTC = '2015-09-23T18:31:44.000Z'; - async ensureHiddenNoDataPopover() { - const isVisible = await testSubjects.exists('noDataPopoverDismissButton'); - if (isVisible) { - await testSubjects.click('noDataPopoverDismissButton'); - } - } + async setDefaultAbsoluteRange() { + await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime); + } - /** - * the provides a quicker way to set the timepicker to the default range, saves a few seconds - */ - async setDefaultAbsoluteRangeViaUiSettings() { - await kibanaServer.uiSettings.update({ - 'timepicker:timeDefaults': `{ "from": "${this.defaultStartTimeUTC}", "to": "${this.defaultEndTimeUTC}"}`, - }); + async ensureHiddenNoDataPopover() { + const isVisible = await this.testSubjects.exists('noDataPopoverDismissButton'); + if (isVisible) { + await this.testSubjects.click('noDataPopoverDismissButton'); } + } - async resetDefaultAbsoluteRangeViaUiSettings() { - await kibanaServer.uiSettings.replace({}); - } + /** + * the provides a quicker way to set the timepicker to the default range, saves a few seconds + */ + async setDefaultAbsoluteRangeViaUiSettings() { + await this.kibanaServer.uiSettings.update({ + 'timepicker:timeDefaults': `{ "from": "${this.defaultStartTimeUTC}", "to": "${this.defaultEndTimeUTC}"}`, + }); + } - private async getTimePickerPanel() { - return await retry.try(async () => { - return await find.byCssSelector('div.euiPopover__panel-isOpen'); - }); - } + async resetDefaultAbsoluteRangeViaUiSettings() { + await this.kibanaServer.uiSettings.replace({}); + } - private async waitPanelIsGone(panelElement: WebElementWrapper) { - await find.waitForElementStale(panelElement); - } + private async getTimePickerPanel() { + return await this.retry.try(async () => { + return await this.find.byCssSelector('div.euiPopover__panel-isOpen'); + }); + } - public async timePickerExists() { - return await testSubjects.exists('superDatePickerToggleQuickMenuButton'); - } + private async waitPanelIsGone(panelElement: WebElementWrapper) { + await this.find.waitForElementStale(panelElement); + } - /** - * Sets commonly used time - * @param option 'Today' | 'This_week' | 'Last_15 minutes' | 'Last_24 hours' ... - */ - async setCommonlyUsedTime(option: CommonlyUsed | string) { - await testSubjects.click('superDatePickerToggleQuickMenuButton'); - await testSubjects.click(`superDatePickerCommonlyUsed_${option}`); - } + public async timePickerExists() { + return await this.testSubjects.exists('superDatePickerToggleQuickMenuButton'); + } - public async inputValue(dataTestSubj: string, value: string) { - if (browser.isFirefox) { - const input = await testSubjects.find(dataTestSubj); - await input.clearValue(); - await input.type(value); - } else { - await testSubjects.setValue(dataTestSubj, value); - } - } + /** + * Sets commonly used time + * @param option 'Today' | 'This_week' | 'Last_15 minutes' | 'Last_24 hours' ... + */ + async setCommonlyUsedTime(option: CommonlyUsed | string) { + await this.testSubjects.click('superDatePickerToggleQuickMenuButton'); + await this.testSubjects.click(`superDatePickerCommonlyUsed_${option}`); + } - private async showStartEndTimes() { - // This first await makes sure the superDatePicker has loaded before we check for the ShowDatesButton - await testSubjects.exists('superDatePickerToggleQuickMenuButton', { timeout: 20000 }); - const isShowDatesButton = await testSubjects.exists('superDatePickerShowDatesButton'); - if (isShowDatesButton) { - await testSubjects.click('superDatePickerShowDatesButton'); - } - await testSubjects.exists('superDatePickerstartDatePopoverButton'); + public async inputValue(dataTestSubj: string, value: string) { + if (this.browser.isFirefox) { + const input = await this.testSubjects.find(dataTestSubj); + await input.clearValue(); + await input.type(value); + } else { + await this.testSubjects.setValue(dataTestSubj, value); } + } - /** - * @param {String} fromTime MMM D, YYYY @ HH:mm:ss.SSS - * @param {String} toTime MMM D, YYYY @ HH:mm:ss.SSS - */ - public async setAbsoluteRange(fromTime: string, toTime: string) { - log.debug(`Setting absolute range to ${fromTime} to ${toTime}`); - await this.showStartEndTimes(); - - // set to time - await testSubjects.click('superDatePickerendDatePopoverButton'); - let panel = await this.getTimePickerPanel(); - await testSubjects.click('superDatePickerAbsoluteTab'); - await testSubjects.click('superDatePickerAbsoluteDateInput'); - await this.inputValue('superDatePickerAbsoluteDateInput', toTime); - await browser.pressKeys(browser.keys.ESCAPE); // close popover because sometimes browser can't find start input - - // set from time - await testSubjects.click('superDatePickerstartDatePopoverButton'); - await this.waitPanelIsGone(panel); - panel = await this.getTimePickerPanel(); - await testSubjects.click('superDatePickerAbsoluteTab'); - await testSubjects.click('superDatePickerAbsoluteDateInput'); - await this.inputValue('superDatePickerAbsoluteDateInput', fromTime); - - const superDatePickerApplyButtonExists = await testSubjects.exists( - 'superDatePickerApplyTimeButton' - ); - if (superDatePickerApplyButtonExists) { - // Timepicker is in top nav - // Click super date picker apply button to apply time range - await testSubjects.click('superDatePickerApplyTimeButton'); - } else { - // Timepicker is embedded in query bar - // click query bar submit button to apply time range - await testSubjects.click('querySubmitButton'); - } - - await this.waitPanelIsGone(panel); - await header.awaitGlobalLoadingIndicatorHidden(); + private async showStartEndTimes() { + // This first await makes sure the superDatePicker has loaded before we check for the ShowDatesButton + await this.testSubjects.exists('superDatePickerToggleQuickMenuButton', { timeout: 20000 }); + const isShowDatesButton = await this.testSubjects.exists('superDatePickerShowDatesButton'); + if (isShowDatesButton) { + await this.testSubjects.click('superDatePickerShowDatesButton'); } + await this.testSubjects.exists('superDatePickerstartDatePopoverButton'); + } - public async isOff() { - return await find.existsByCssSelector('.euiDatePickerRange--readOnly'); + /** + * @param {String} fromTime MMM D, YYYY @ HH:mm:ss.SSS + * @param {String} toTime MMM D, YYYY @ HH:mm:ss.SSS + */ + public async setAbsoluteRange(fromTime: string, toTime: string) { + this.log.debug(`Setting absolute range to ${fromTime} to ${toTime}`); + await this.showStartEndTimes(); + + // set to time + await this.testSubjects.click('superDatePickerendDatePopoverButton'); + let panel = await this.getTimePickerPanel(); + await this.testSubjects.click('superDatePickerAbsoluteTab'); + await this.testSubjects.click('superDatePickerAbsoluteDateInput'); + await this.inputValue('superDatePickerAbsoluteDateInput', toTime); + await this.browser.pressKeys(this.browser.keys.ESCAPE); // close popover because sometimes browser can't find start input + + // set from time + await this.testSubjects.click('superDatePickerstartDatePopoverButton'); + await this.waitPanelIsGone(panel); + panel = await this.getTimePickerPanel(); + await this.testSubjects.click('superDatePickerAbsoluteTab'); + await this.testSubjects.click('superDatePickerAbsoluteDateInput'); + await this.inputValue('superDatePickerAbsoluteDateInput', fromTime); + + const superDatePickerApplyButtonExists = await this.testSubjects.exists( + 'superDatePickerApplyTimeButton' + ); + if (superDatePickerApplyButtonExists) { + // Timepicker is in top nav + // Click super date picker apply button to apply time range + await this.testSubjects.click('superDatePickerApplyTimeButton'); + } else { + // Timepicker is embedded in query bar + // click query bar submit button to apply time range + await this.testSubjects.click('querySubmitButton'); } - public async getRefreshConfig(keepQuickSelectOpen = false) { - await quickSelectTimeMenuToggle.open(); - const interval = await testSubjects.getAttribute( - 'superDatePickerRefreshIntervalInput', - 'value' - ); - - let selectedUnit; - const select = await testSubjects.find('superDatePickerRefreshIntervalUnitsSelect'); - const options = await find.allDescendantDisplayedByCssSelector('option', select); - await Promise.all( - options.map(async (optionElement) => { - const isSelected = await optionElement.isSelected(); - if (isSelected) { - selectedUnit = await optionElement.getVisibleText(); - } - }) - ); - - const toggleButtonText = await testSubjects.getVisibleText( - 'superDatePickerToggleRefreshButton' - ); - if (!keepQuickSelectOpen) { - await quickSelectTimeMenuToggle.close(); - } - - return { - interval, - units: selectedUnit, - isPaused: toggleButtonText === 'Start' ? true : false, - }; - } + await this.waitPanelIsGone(panel); + await this.header.awaitGlobalLoadingIndicatorHidden(); + } - public async getTimeConfig() { - await this.showStartEndTimes(); - const start = await testSubjects.getVisibleText('superDatePickerstartDatePopoverButton'); - const end = await testSubjects.getVisibleText('superDatePickerendDatePopoverButton'); - return { - start, - end, - }; - } + public async isOff() { + return await this.find.existsByCssSelector('.euiDatePickerRange--readOnly'); + } - public async getShowDatesButtonText() { - const button = await testSubjects.find('superDatePickerShowDatesButton'); - const text = await button.getVisibleText(); - return text; + public async getRefreshConfig(keepQuickSelectOpen = false) { + await this.quickSelectTimeMenuToggle.open(); + const interval = await this.testSubjects.getAttribute( + 'superDatePickerRefreshIntervalInput', + 'value' + ); + + let selectedUnit; + const select = await this.testSubjects.find('superDatePickerRefreshIntervalUnitsSelect'); + const options = await this.find.allDescendantDisplayedByCssSelector('option', select); + await Promise.all( + options.map(async (optionElement) => { + const isSelected = await optionElement.isSelected(); + if (isSelected) { + selectedUnit = await optionElement.getVisibleText(); + } + }) + ); + + const toggleButtonText = await this.testSubjects.getVisibleText( + 'superDatePickerToggleRefreshButton' + ); + if (!keepQuickSelectOpen) { + await this.quickSelectTimeMenuToggle.close(); } - public async getTimeDurationForSharing() { - return await testSubjects.getAttribute( - 'dataSharedTimefilterDuration', - 'data-shared-timefilter-duration' - ); - } + return { + interval, + units: selectedUnit, + isPaused: toggleButtonText === 'Start' ? true : false, + }; + } - public async getTimeConfigAsAbsoluteTimes() { - await this.showStartEndTimes(); - - // get to time - await testSubjects.click('superDatePickerendDatePopoverButton'); - const panel = await this.getTimePickerPanel(); - await testSubjects.click('superDatePickerAbsoluteTab'); - const end = await testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); - - // get from time - await testSubjects.click('superDatePickerstartDatePopoverButton'); - await this.waitPanelIsGone(panel); - await testSubjects.click('superDatePickerAbsoluteTab'); - const start = await testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); - - return { - start, - end, - }; - } + public async getTimeConfig() { + await this.showStartEndTimes(); + const start = await this.testSubjects.getVisibleText('superDatePickerstartDatePopoverButton'); + const end = await this.testSubjects.getVisibleText('superDatePickerendDatePopoverButton'); + return { + start, + end, + }; + } - public async getTimeDurationInHours() { - const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; - const { start, end } = await this.getTimeConfigAsAbsoluteTimes(); - const startMoment = moment(start, DEFAULT_DATE_FORMAT); - const endMoment = moment(end, DEFAULT_DATE_FORMAT); - return moment.duration(endMoment.diff(startMoment)).asHours(); - } + public async getShowDatesButtonText() { + const button = await this.testSubjects.find('superDatePickerShowDatesButton'); + const text = await button.getVisibleText(); + return text; + } - public async startAutoRefresh(intervalS = 3) { - await quickSelectTimeMenuToggle.open(); - await this.inputValue('superDatePickerRefreshIntervalInput', intervalS.toString()); - const refreshConfig = await this.getRefreshConfig(true); - if (refreshConfig.isPaused) { - log.debug('start auto refresh'); - await testSubjects.click('superDatePickerToggleRefreshButton'); - } - await quickSelectTimeMenuToggle.close(); - } + public async getTimeDurationForSharing() { + return await this.testSubjects.getAttribute( + 'dataSharedTimefilterDuration', + 'data-shared-timefilter-duration' + ); + } - public async pauseAutoRefresh() { - log.debug('pauseAutoRefresh'); - const refreshConfig = await this.getRefreshConfig(true); + public async getTimeConfigAsAbsoluteTimes() { + await this.showStartEndTimes(); + + // get to time + await this.testSubjects.click('superDatePickerendDatePopoverButton'); + const panel = await this.getTimePickerPanel(); + await this.testSubjects.click('superDatePickerAbsoluteTab'); + const end = await this.testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); + + // get from time + await this.testSubjects.click('superDatePickerstartDatePopoverButton'); + await this.waitPanelIsGone(panel); + await this.testSubjects.click('superDatePickerAbsoluteTab'); + const start = await this.testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); + + return { + start, + end, + }; + } - if (!refreshConfig.isPaused) { - log.debug('pause auto refresh'); - await testSubjects.click('superDatePickerToggleRefreshButton'); - } + public async getTimeDurationInHours() { + const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; + const { start, end } = await this.getTimeConfigAsAbsoluteTimes(); + const startMoment = moment(start, DEFAULT_DATE_FORMAT); + const endMoment = moment(end, DEFAULT_DATE_FORMAT); + return moment.duration(endMoment.diff(startMoment)).asHours(); + } - await quickSelectTimeMenuToggle.close(); + public async startAutoRefresh(intervalS = 3) { + await this.quickSelectTimeMenuToggle.open(); + await this.inputValue('superDatePickerRefreshIntervalInput', intervalS.toString()); + const refreshConfig = await this.getRefreshConfig(true); + if (refreshConfig.isPaused) { + this.log.debug('start auto refresh'); + await this.testSubjects.click('superDatePickerToggleRefreshButton'); } + await this.quickSelectTimeMenuToggle.close(); + } - public async resumeAutoRefresh() { - log.debug('resumeAutoRefresh'); - const refreshConfig = await this.getRefreshConfig(true); - if (refreshConfig.isPaused) { - log.debug('resume auto refresh'); - await testSubjects.click('superDatePickerToggleRefreshButton'); - } + public async pauseAutoRefresh() { + this.log.debug('pauseAutoRefresh'); + const refreshConfig = await this.getRefreshConfig(true); - await quickSelectTimeMenuToggle.close(); + if (!refreshConfig.isPaused) { + this.log.debug('pause auto refresh'); + await this.testSubjects.click('superDatePickerToggleRefreshButton'); } - public async setHistoricalDataRange() { - await this.setDefaultAbsoluteRange(); - } + await this.quickSelectTimeMenuToggle.close(); + } - public async setDefaultDataRange() { - const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; - const toTime = 'Apr 13, 2018 @ 00:00:00.000'; - await this.setAbsoluteRange(fromTime, toTime); + public async resumeAutoRefresh() { + this.log.debug('resumeAutoRefresh'); + const refreshConfig = await this.getRefreshConfig(true); + if (refreshConfig.isPaused) { + this.log.debug('resume auto refresh'); + await this.testSubjects.click('superDatePickerToggleRefreshButton'); } - public async setLogstashDataRange() { - const fromTime = 'Apr 9, 2018 @ 00:00:00.000'; - const toTime = 'Apr 13, 2018 @ 00:00:00.000'; - await this.setAbsoluteRange(fromTime, toTime); - } + await this.quickSelectTimeMenuToggle.close(); } - return new TimePicker(); + public async setHistoricalDataRange() { + await this.setDefaultAbsoluteRange(); + } + + public async setDefaultDataRange() { + const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; + const toTime = 'Apr 13, 2018 @ 00:00:00.000'; + await this.setAbsoluteRange(fromTime, toTime); + } + + public async setLogstashDataRange() { + const fromTime = 'Apr 9, 2018 @ 00:00:00.000'; + const toTime = 'Apr 13, 2018 @ 00:00:00.000'; + await this.setAbsoluteRange(fromTime, toTime); + } } diff --git a/test/functional/page_objects/time_to_visualize_page.ts b/test/functional/page_objects/time_to_visualize_page.ts index 458b4dd3e60a13..287b03ec60d88a 100644 --- a/test/functional/page_objects/time_to_visualize_page.ts +++ b/test/functional/page_objects/time_to_visualize_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; interface SaveModalArgs { addToDashboard?: 'new' | 'existing' | null; @@ -21,117 +21,108 @@ type DashboardPickerOption = | 'existing-dashboard-option' | 'new-dashboard-option'; -export function TimeToVisualizePageProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const log = getService('log'); - const find = getService('find'); - const { common, dashboard } = getPageObjects(['common', 'dashboard']); +export class TimeToVisualizePageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly log = this.ctx.getService('log'); + private readonly find = this.ctx.getService('find'); + private readonly common = this.ctx.getPageObject('common'); + private readonly dashboard = this.ctx.getPageObject('dashboard'); - class TimeToVisualizePage { - public async ensureSaveModalIsOpen() { - await testSubjects.exists('savedObjectSaveModal', { timeout: 5000 }); - } + public async ensureSaveModalIsOpen() { + await this.testSubjects.exists('savedObjectSaveModal', { timeout: 5000 }); + } - public async ensureDashboardOptionsAreDisabled() { - const dashboardSelector = await testSubjects.find('add-to-dashboard-options'); - await dashboardSelector.findByCssSelector(`input[id="new-dashboard-option"]:disabled`); - await dashboardSelector.findByCssSelector(`input[id="existing-dashboard-option"]:disabled`); + public async ensureDashboardOptionsAreDisabled() { + const dashboardSelector = await this.testSubjects.find('add-to-dashboard-options'); + await dashboardSelector.findByCssSelector(`input[id="new-dashboard-option"]:disabled`); + await dashboardSelector.findByCssSelector(`input[id="existing-dashboard-option"]:disabled`); - const librarySelector = await testSubjects.find('add-to-library-checkbox'); - await librarySelector.findByCssSelector(`input[id="add-to-library-checkbox"]:disabled`); - } - - public async resetNewDashboard() { - await common.navigateToApp('dashboard'); - await dashboard.gotoDashboardLandingPage(true); - await dashboard.clickNewDashboard(false); - } + const librarySelector = await this.testSubjects.find('add-to-library-checkbox'); + await librarySelector.findByCssSelector(`input[id="add-to-library-checkbox"]:disabled`); + } - public async setSaveModalValues( - vizName: string, - { - saveAsNew, - redirectToOrigin, - addToDashboard, - dashboardId, - saveToLibrary, - }: SaveModalArgs = {} - ) { - await testSubjects.setValue('savedObjectTitle', vizName); - - const hasSaveAsNew = await testSubjects.exists('saveAsNewCheckbox'); - if (hasSaveAsNew && saveAsNew !== undefined) { - const state = saveAsNew ? 'check' : 'uncheck'; - log.debug('save as new checkbox exists. Setting its state to', state); - await testSubjects.setEuiSwitch('saveAsNewCheckbox', state); - } + public async resetNewDashboard() { + await this.common.navigateToApp('dashboard'); + await this.dashboard.gotoDashboardLandingPage(true); + await this.dashboard.clickNewDashboard(false); + } - const hasDashboardSelector = await testSubjects.exists('add-to-dashboard-options'); - if (hasDashboardSelector && addToDashboard !== undefined) { - let option: DashboardPickerOption = 'add-to-library-option'; - if (addToDashboard) { - option = dashboardId ? 'existing-dashboard-option' : 'new-dashboard-option'; - } - log.debug('save modal dashboard selector, choosing option:', option); - const dashboardSelector = await testSubjects.find('add-to-dashboard-options'); - const label = await dashboardSelector.findByCssSelector(`label[for="${option}"]`); - await label.click(); + public async setSaveModalValues( + vizName: string, + { saveAsNew, redirectToOrigin, addToDashboard, dashboardId, saveToLibrary }: SaveModalArgs = {} + ) { + await this.testSubjects.setValue('savedObjectTitle', vizName); + + const hasSaveAsNew = await this.testSubjects.exists('saveAsNewCheckbox'); + if (hasSaveAsNew && saveAsNew !== undefined) { + const state = saveAsNew ? 'check' : 'uncheck'; + this.log.debug('save as new checkbox exists. Setting its state to', state); + await this.testSubjects.setEuiSwitch('saveAsNewCheckbox', state); + } - if (dashboardId) { - await testSubjects.setValue('dashboardPickerInput', dashboardId); - await find.clickByButtonText(dashboardId); - } + const hasDashboardSelector = await this.testSubjects.exists('add-to-dashboard-options'); + if (hasDashboardSelector && addToDashboard !== undefined) { + let option: DashboardPickerOption = 'add-to-library-option'; + if (addToDashboard) { + option = dashboardId ? 'existing-dashboard-option' : 'new-dashboard-option'; } - - const hasSaveToLibrary = await testSubjects.exists('add-to-library-checkbox'); - if (hasSaveToLibrary && saveToLibrary !== undefined) { - const libraryCheckbox = await find.byCssSelector('#add-to-library-checkbox'); - const isChecked = await libraryCheckbox.isSelected(); - const needsClick = isChecked !== saveToLibrary; - const state = saveToLibrary ? 'check' : 'uncheck'; - - log.debug('save to library checkbox exists. Setting its state to', state); - if (needsClick) { - const selector = await testSubjects.find('add-to-library-checkbox'); - const label = await selector.findByCssSelector(`label[for="add-to-library-checkbox"]`); - await label.click(); - } + this.log.debug('save modal dashboard selector, choosing option:', option); + const dashboardSelector = await this.testSubjects.find('add-to-dashboard-options'); + const label = await dashboardSelector.findByCssSelector(`label[for="${option}"]`); + await label.click(); + + if (dashboardId) { + await this.testSubjects.setValue('dashboardPickerInput', dashboardId); + await this.find.clickByButtonText(dashboardId); } + } - const hasRedirectToOrigin = await testSubjects.exists('returnToOriginModeSwitch'); - if (hasRedirectToOrigin && redirectToOrigin !== undefined) { - const state = redirectToOrigin ? 'check' : 'uncheck'; - log.debug('redirect to origin checkbox exists. Setting its state to', state); - await testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); + const hasSaveToLibrary = await this.testSubjects.exists('add-to-library-checkbox'); + if (hasSaveToLibrary && saveToLibrary !== undefined) { + const libraryCheckbox = await this.find.byCssSelector('#add-to-library-checkbox'); + const isChecked = await libraryCheckbox.isSelected(); + const needsClick = isChecked !== saveToLibrary; + const state = saveToLibrary ? 'check' : 'uncheck'; + + this.log.debug('save to library checkbox exists. Setting its state to', state); + if (needsClick) { + const selector = await this.testSubjects.find('add-to-library-checkbox'); + const label = await selector.findByCssSelector(`label[for="add-to-library-checkbox"]`); + await label.click(); } } - public async libraryNotificationExists(panelTitle: string) { - log.debug('searching for library modal on panel:', panelTitle); - const panel = await testSubjects.find( - `embeddablePanelHeading-${panelTitle.replace(/ /g, '')}` - ); - const libraryActionExists = await testSubjects.descendantExists( - 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', - panel - ); - return libraryActionExists; + const hasRedirectToOrigin = await this.testSubjects.exists('returnToOriginModeSwitch'); + if (hasRedirectToOrigin && redirectToOrigin !== undefined) { + const state = redirectToOrigin ? 'check' : 'uncheck'; + this.log.debug('redirect to origin checkbox exists. Setting its state to', state); + await this.testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); } + } - public async saveFromModal( - vizName: string, - saveModalArgs: SaveModalArgs = { addToDashboard: null } - ) { - await this.ensureSaveModalIsOpen(); + public async libraryNotificationExists(panelTitle: string) { + this.log.debug('searching for library modal on panel:', panelTitle); + const panel = await this.testSubjects.find( + `embeddablePanelHeading-${panelTitle.replace(/ /g, '')}` + ); + const libraryActionExists = await this.testSubjects.descendantExists( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + panel + ); + return libraryActionExists; + } - await this.setSaveModalValues(vizName, saveModalArgs); - log.debug('Click Save Visualization button'); + public async saveFromModal( + vizName: string, + saveModalArgs: SaveModalArgs = { addToDashboard: null } + ) { + await this.ensureSaveModalIsOpen(); - await testSubjects.click('confirmSaveSavedObjectButton'); + await this.setSaveModalValues(vizName, saveModalArgs); + this.log.debug('Click Save Visualization button'); - await common.waitForSaveModalToClose(); - } - } + await this.testSubjects.click('confirmSaveSavedObjectButton'); - return new TimeToVisualizePage(); + await this.common.waitForSaveModalToClose(); + } } diff --git a/test/functional/page_objects/timelion_page.ts b/test/functional/page_objects/timelion_page.ts index 7f4c4eb125c8e1..57913f8e2413dc 100644 --- a/test/functional/page_objects/timelion_page.ts +++ b/test/functional/page_objects/timelion_page.ts @@ -6,79 +6,75 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function TimelionPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const log = getService('log'); - const PageObjects = getPageObjects(['common', 'header']); - const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); +export class TimelionPageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly log = this.ctx.getService('log'); + private readonly common = this.ctx.getPageObject('common'); + private readonly esArchiver = this.ctx.getService('esArchiver'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); - class TimelionPage { - public async initTests() { - await kibanaServer.uiSettings.replace({ - defaultIndex: 'logstash-*', - }); + public async initTests() { + await this.kibanaServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + }); - log.debug('load kibana index'); - await esArchiver.load('timelion'); + this.log.debug('load kibana index'); + await this.esArchiver.load('timelion'); - await PageObjects.common.navigateToApp('timelion'); - } - - public async setExpression(expression: string) { - const input = await testSubjects.find('timelionExpressionTextArea'); - await input.clearValue(); - await input.type(expression); - } + await this.common.navigateToApp('timelion'); + } - public async updateExpression(updates: string) { - const input = await testSubjects.find('timelionExpressionTextArea'); - await input.type(updates); - await PageObjects.common.sleep(1000); - } + public async setExpression(expression: string) { + const input = await this.testSubjects.find('timelionExpressionTextArea'); + await input.clearValue(); + await input.type(expression); + } - public async getExpression() { - const input = await testSubjects.find('timelionExpressionTextArea'); - return input.getVisibleText(); - } + public async updateExpression(updates: string) { + const input = await this.testSubjects.find('timelionExpressionTextArea'); + await input.type(updates); + await this.common.sleep(1000); + } - public async getSuggestionItemsText() { - const elements = await testSubjects.findAll('timelionSuggestionListItem'); - return await Promise.all(elements.map(async (element) => await element.getVisibleText())); - } + public async getExpression() { + const input = await this.testSubjects.find('timelionExpressionTextArea'); + return input.getVisibleText(); + } - public async clickSuggestion(suggestionIndex = 0, waitTime = 1000) { - const elements = await testSubjects.findAll('timelionSuggestionListItem'); - if (suggestionIndex > elements.length) { - throw new Error( - `Unable to select suggestion ${suggestionIndex}, only ${elements.length} suggestions available.` - ); - } - await elements[suggestionIndex].click(); - // Wait for timelion expression to be updated after clicking suggestions - await PageObjects.common.sleep(waitTime); - } + public async getSuggestionItemsText() { + const elements = await this.testSubjects.findAll('timelionSuggestionListItem'); + return await Promise.all(elements.map(async (element) => await element.getVisibleText())); + } - public async saveTimelionSheet() { - await testSubjects.click('timelionSaveButton'); - await testSubjects.click('timelionSaveAsSheetButton'); - await testSubjects.click('timelionFinishSaveButton'); - await testSubjects.existOrFail('timelionSaveSuccessToast'); - await testSubjects.waitForDeleted('timelionSaveSuccessToast'); + public async clickSuggestion(suggestionIndex = 0, waitTime = 1000) { + const elements = await this.testSubjects.findAll('timelionSuggestionListItem'); + if (suggestionIndex > elements.length) { + throw new Error( + `Unable to select suggestion ${suggestionIndex}, only ${elements.length} suggestions available.` + ); } + await elements[suggestionIndex].click(); + // Wait for timelion expression to be updated after clicking suggestions + await this.common.sleep(waitTime); + } - public async expectWriteControls() { - await testSubjects.existOrFail('timelionSaveButton'); - await testSubjects.existOrFail('timelionDeleteButton'); - } + public async saveTimelionSheet() { + await this.testSubjects.click('timelionSaveButton'); + await this.testSubjects.click('timelionSaveAsSheetButton'); + await this.testSubjects.click('timelionFinishSaveButton'); + await this.testSubjects.existOrFail('timelionSaveSuccessToast'); + await this.testSubjects.waitForDeleted('timelionSaveSuccessToast'); + } - public async expectMissingWriteControls() { - await testSubjects.missingOrFail('timelionSaveButton'); - await testSubjects.missingOrFail('timelionDeleteButton'); - } + public async expectWriteControls() { + await this.testSubjects.existOrFail('timelionSaveButton'); + await this.testSubjects.existOrFail('timelionDeleteButton'); } - return new TimelionPage(); + public async expectMissingWriteControls() { + await this.testSubjects.missingOrFail('timelionSaveButton'); + await this.testSubjects.missingOrFail('timelionDeleteButton'); + } } diff --git a/test/functional/page_objects/vega_chart_page.ts b/test/functional/page_objects/vega_chart_page.ts index 3e165b3434f8a4..f83c5e193034eb 100644 --- a/test/functional/page_objects/vega_chart_page.ts +++ b/test/functional/page_objects/vega_chart_page.ts @@ -7,110 +7,103 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; const compareSpecs = (first: string, second: string) => { const normalizeSpec = (spec: string) => spec.replace(/[\n ]/g, ''); return normalizeSpec(first) === normalizeSpec(second); }; -export function VegaChartPageProvider({ - getService, - getPageObjects, -}: FtrProviderContext & { updateBaselines: boolean }) { - const find = getService('find'); - const testSubjects = getService('testSubjects'); - const browser = getService('browser'); - const retry = getService('retry'); - - class VegaChartPage { - public getEditor() { - return testSubjects.find('vega-editor'); - } +export class VegaChartPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly browser = this.ctx.getService('browser'); + private readonly retry = this.ctx.getService('retry'); - public getViewContainer() { - return find.byCssSelector('div.vgaVis__view'); - } + public getEditor() { + return this.testSubjects.find('vega-editor'); + } - public getControlContainer() { - return find.byCssSelector('div.vgaVis__controls'); - } + public getViewContainer() { + return this.find.byCssSelector('div.vgaVis__view'); + } - public getYAxisContainer() { - return find.byCssSelector('[aria-label^="Y-axis"]'); - } + public getControlContainer() { + return this.find.byCssSelector('div.vgaVis__controls'); + } - public async getAceGutterContainer() { - const editor = await this.getEditor(); - return editor.findByClassName('ace_gutter'); - } + public getYAxisContainer() { + return this.find.byCssSelector('[aria-label^="Y-axis"]'); + } - public async getRawSpec() { - // Adapted from console_page.js:getVisibleTextFromAceEditor(). Is there a common utilities file? - const editor = await this.getEditor(); - const lines = await editor.findAllByClassName('ace_line_group'); + public async getAceGutterContainer() { + const editor = await this.getEditor(); + return editor.findByClassName('ace_gutter'); + } - return await Promise.all( - lines.map(async (line) => { - return await line.getVisibleText(); - }) - ); - } + public async getRawSpec() { + // Adapted from console_page.js:getVisibleTextFromAceEditor(). Is there a common utilities file? + const editor = await this.getEditor(); + const lines = await editor.findAllByClassName('ace_line_group'); - public async getSpec() { - return (await this.getRawSpec()).join('\n'); - } + return await Promise.all( + lines.map(async (line) => { + return await line.getVisibleText(); + }) + ); + } - public async focusEditor() { - const editor = await this.getEditor(); - const textarea = await editor.findByClassName('ace_content'); + public async getSpec() { + return (await this.getRawSpec()).join('\n'); + } - await textarea.click(); - } + public async focusEditor() { + const editor = await this.getEditor(); + const textarea = await editor.findByClassName('ace_content'); - public async fillSpec(newSpec: string) { - await retry.try(async () => { - await this.cleanSpec(); - await this.focusEditor(); - await browser.pressKeys(newSpec); + await textarea.click(); + } - expect(compareSpecs(await this.getSpec(), newSpec)).to.be(true); - }); - } + public async fillSpec(newSpec: string) { + await this.retry.try(async () => { + await this.cleanSpec(); + await this.focusEditor(); + await this.browser.pressKeys(newSpec); - public async typeInSpec(text: string) { - const aceGutter = await this.getAceGutterContainer(); + expect(compareSpecs(await this.getSpec(), newSpec)).to.be(true); + }); + } - await aceGutter.doubleClick(); - await browser.pressKeys(browser.keys.RIGHT); - await browser.pressKeys(browser.keys.LEFT); - await browser.pressKeys(browser.keys.LEFT); - await browser.pressKeys(text); - } + public async typeInSpec(text: string) { + const aceGutter = await this.getAceGutterContainer(); - public async cleanSpec() { - const aceGutter = await this.getAceGutterContainer(); + await aceGutter.doubleClick(); + await this.browser.pressKeys(this.browser.keys.RIGHT); + await this.browser.pressKeys(this.browser.keys.LEFT); + await this.browser.pressKeys(this.browser.keys.LEFT); + await this.browser.pressKeys(text); + } - await retry.try(async () => { - await aceGutter.doubleClick(); - await browser.pressKeys(browser.keys.BACK_SPACE); + public async cleanSpec() { + const aceGutter = await this.getAceGutterContainer(); - expect(await this.getSpec()).to.be(''); - }); - } + await this.retry.try(async () => { + await aceGutter.doubleClick(); + await this.browser.pressKeys(this.browser.keys.BACK_SPACE); - public async getYAxisLabels() { - const yAxis = await this.getYAxisContainer(); - const tickGroup = await yAxis.findByClassName('role-axis-label'); - const labels = await tickGroup.findAllByCssSelector('text'); - const labelTexts: string[] = []; + expect(await this.getSpec()).to.be(''); + }); + } + + public async getYAxisLabels() { + const yAxis = await this.getYAxisContainer(); + const tickGroup = await yAxis.findByClassName('role-axis-label'); + const labels = await tickGroup.findAllByCssSelector('text'); + const labelTexts: string[] = []; - for (const label of labels) { - labelTexts.push(await label.getVisibleText()); - } - return labelTexts; + for (const label of labels) { + labelTexts.push(await label.getVisibleText()); } + return labelTexts; } - - return new VegaChartPage(); } diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index 997a1127005ee5..d796067372fa87 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -6,654 +6,651 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; -export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const find = getService('find'); - const log = getService('log'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const comboBox = getService('comboBox'); - const PageObjects = getPageObjects(['common', 'header', 'visualize', 'timePicker', 'visChart']); - - type Duration = - | 'Milliseconds' - | 'Seconds' - | 'Minutes' - | 'Hours' - | 'Days' - | 'Weeks' - | 'Months' - | 'Years'; - - type FromDuration = Duration | 'Picoseconds' | 'Nanoseconds' | 'Microseconds'; - type ToDuration = Duration | 'Human readable'; - - class VisualBuilderPage { - public async resetPage( - fromTime = 'Sep 19, 2015 @ 06:31:44.000', - toTime = 'Sep 22, 2015 @ 18:31:44.000' - ) { - await PageObjects.common.navigateToUrl('visualize', 'create?type=metrics', { - useActualUrl: true, - }); - log.debug('Wait for initializing TSVB editor'); - await this.checkVisualBuilderIsPresent(); - log.debug('Set absolute time range from "' + fromTime + '" to "' + toTime + '"'); - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - // 2 sec sleep until https://github.com/elastic/kibana/issues/46353 is fixed - await PageObjects.common.sleep(2000); - } +type Duration = + | 'Milliseconds' + | 'Seconds' + | 'Minutes' + | 'Hours' + | 'Days' + | 'Weeks' + | 'Months' + | 'Years'; + +type FromDuration = Duration | 'Picoseconds' | 'Nanoseconds' | 'Microseconds'; +type ToDuration = Duration | 'Human readable'; + +export class VisualBuilderPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly comboBox = this.ctx.getService('comboBox'); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); + private readonly timePicker = this.ctx.getPageObject('timePicker'); + private readonly visChart = this.ctx.getPageObject('visChart'); + + public async resetPage( + fromTime = 'Sep 19, 2015 @ 06:31:44.000', + toTime = 'Sep 22, 2015 @ 18:31:44.000' + ) { + await this.common.navigateToUrl('visualize', 'create?type=metrics', { + useActualUrl: true, + }); + this.log.debug('Wait for initializing TSVB editor'); + await this.checkVisualBuilderIsPresent(); + this.log.debug('Set absolute time range from "' + fromTime + '" to "' + toTime + '"'); + await this.timePicker.setAbsoluteRange(fromTime, toTime); + // 2 sec sleep until https://github.com/elastic/kibana/issues/46353 is fixed + await this.common.sleep(2000); + } - public async checkTabIsLoaded(testSubj: string, name: string) { - let isPresent = false; - await retry.try(async () => { - isPresent = await testSubjects.exists(testSubj, { timeout: 20000 }); - if (!isPresent) { - isPresent = await testSubjects.exists('visNoResult', { timeout: 1000 }); - } - }); + public async checkTabIsLoaded(testSubj: string, name: string) { + let isPresent = false; + await this.retry.try(async () => { + isPresent = await this.testSubjects.exists(testSubj, { timeout: 20000 }); if (!isPresent) { - throw new Error(`TSVB ${name} tab is not loaded`); + isPresent = await this.testSubjects.exists('visNoResult', { timeout: 1000 }); } + }); + if (!isPresent) { + throw new Error(`TSVB ${name} tab is not loaded`); } + } - public async checkTabIsSelected(chartType: string) { - const chartTypeBtn = await testSubjects.find(`${chartType}TsvbTypeBtn`); - const isSelected = await chartTypeBtn.getAttribute('aria-selected'); + public async checkTabIsSelected(chartType: string) { + const chartTypeBtn = await this.testSubjects.find(`${chartType}TsvbTypeBtn`); + const isSelected = await chartTypeBtn.getAttribute('aria-selected'); - if (isSelected !== 'true') { - throw new Error(`TSVB ${chartType} tab is not selected`); - } + if (isSelected !== 'true') { + throw new Error(`TSVB ${chartType} tab is not selected`); } + } - public async checkPanelConfigIsPresent(chartType: string) { - await testSubjects.existOrFail(`tvbPanelConfig__${chartType}`); - } + public async checkPanelConfigIsPresent(chartType: string) { + await this.testSubjects.existOrFail(`tvbPanelConfig__${chartType}`); + } - public async checkVisualBuilderIsPresent() { - await this.checkTabIsLoaded('tvbVisEditor', 'Time Series'); - } + public async checkVisualBuilderIsPresent() { + await this.checkTabIsLoaded('tvbVisEditor', 'Time Series'); + } - public async checkTimeSeriesChartIsPresent() { - const isPresent = await find.existsByCssSelector('.tvbVisTimeSeries'); - if (!isPresent) { - throw new Error(`TimeSeries chart is not loaded`); - } + public async checkTimeSeriesChartIsPresent() { + const isPresent = await this.find.existsByCssSelector('.tvbVisTimeSeries'); + if (!isPresent) { + throw new Error(`TimeSeries chart is not loaded`); } + } - public async checkTimeSeriesIsLight() { - return await find.existsByCssSelector('.tvbVisTimeSeriesLight'); - } + public async checkTimeSeriesIsLight() { + return await this.find.existsByCssSelector('.tvbVisTimeSeriesLight'); + } - public async checkTimeSeriesLegendIsPresent() { - const isPresent = await find.existsByCssSelector('.echLegend'); - if (!isPresent) { - throw new Error(`TimeSeries legend is not loaded`); - } + public async checkTimeSeriesLegendIsPresent() { + const isPresent = await this.find.existsByCssSelector('.echLegend'); + if (!isPresent) { + throw new Error(`TimeSeries legend is not loaded`); } + } - public async checkMetricTabIsPresent() { - await this.checkTabIsLoaded('tsvbMetricValue', 'Metric'); - } + public async checkMetricTabIsPresent() { + await this.checkTabIsLoaded('tsvbMetricValue', 'Metric'); + } - public async checkGaugeTabIsPresent() { - await this.checkTabIsLoaded('tvbVisGaugeContainer', 'Gauge'); - } + public async checkGaugeTabIsPresent() { + await this.checkTabIsLoaded('tvbVisGaugeContainer', 'Gauge'); + } - public async checkTopNTabIsPresent() { - await this.checkTabIsLoaded('tvbVisTopNTable', 'TopN'); - } + public async checkTopNTabIsPresent() { + await this.checkTabIsLoaded('tvbVisTopNTable', 'TopN'); + } - public async clickMetric() { - const button = await testSubjects.find('metricTsvbTypeBtn'); - await button.click(); - } + public async clickMetric() { + const button = await this.testSubjects.find('metricTsvbTypeBtn'); + await button.click(); + } - public async clickMarkdown() { - const button = await testSubjects.find('markdownTsvbTypeBtn'); - await button.click(); - } + public async clickMarkdown() { + const button = await this.testSubjects.find('markdownTsvbTypeBtn'); + await button.click(); + } - public async getMetricValue() { - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const metricValue = await find.byCssSelector('.tvbVisMetric__value--primary'); - return metricValue.getVisibleText(); - } + public async getMetricValue() { + await this.visChart.waitForVisualizationRenderingStabilized(); + const metricValue = await this.find.byCssSelector('.tvbVisMetric__value--primary'); + return metricValue.getVisibleText(); + } - public async enterMarkdown(markdown: string) { - await this.clearMarkdown(); - const input = await find.byCssSelector('.tvbMarkdownEditor__editor textarea'); - await input.type(markdown); - await PageObjects.common.sleep(3000); - } + public async enterMarkdown(markdown: string) { + await this.clearMarkdown(); + const input = await this.find.byCssSelector('.tvbMarkdownEditor__editor textarea'); + await input.type(markdown); + await this.common.sleep(3000); + } - public async clearMarkdown() { - // Since we use ACE editor and that isn't really storing its value inside - // a textarea we must really select all text and remove it, and cannot use - // clearValue(). - await retry.waitForWithTimeout('text area is cleared', 20000, async () => { - const editor = await testSubjects.find('codeEditorContainer'); - const $ = await editor.parseDomContent(); - const value = $('.ace_line').text(); - if (value.length > 0) { - log.debug('Clearing text area input'); - this.waitForMarkdownTextAreaCleaned(); - } - - return value.length === 0; - }); - } + public async clearMarkdown() { + // Since we use ACE editor and that isn't really storing its value inside + // a textarea we must really select all text and remove it, and cannot use + // clearValue(). + await this.retry.waitForWithTimeout('text area is cleared', 20000, async () => { + const editor = await this.testSubjects.find('codeEditorContainer'); + const $ = await editor.parseDomContent(); + const value = $('.ace_line').text(); + if (value.length > 0) { + this.log.debug('Clearing text area input'); + this.waitForMarkdownTextAreaCleaned(); + } - public async waitForMarkdownTextAreaCleaned() { - const input = await find.byCssSelector('.tvbMarkdownEditor__editor textarea'); - await input.clearValueWithKeyboard(); - const text = await this.getMarkdownText(); - return text.length === 0; - } + return value.length === 0; + }); + } - public async getMarkdownText(): Promise { - const el = await find.byCssSelector('.tvbVis'); - const text = await el.getVisibleText(); - return text; - } + public async waitForMarkdownTextAreaCleaned() { + const input = await this.find.byCssSelector('.tvbMarkdownEditor__editor textarea'); + await input.clearValueWithKeyboard(); + const text = await this.getMarkdownText(); + return text.length === 0; + } - /** - * - * getting all markdown variables list which located on `table` section - * - * **Note**: if `table` not have variables, use `getMarkdownTableNoVariables` method instead - * @see {getMarkdownTableNoVariables} - * @returns {Promise>} - * @memberof VisualBuilderPage - */ - public async getMarkdownTableVariables(): Promise< - Array<{ key: string; value: string; selector: WebElementWrapper }> - > { - const testTableVariables = await testSubjects.find('tsvbMarkdownVariablesTable'); - const variablesSelector = 'tbody tr'; - const exists = await find.existsByCssSelector(variablesSelector); - if (!exists) { - log.debug('variable list is empty'); - return []; - } - const variables = await testTableVariables.findAllByCssSelector(variablesSelector); - - const variablesKeyValueSelectorMap = await Promise.all( - variables.map(async (variable) => { - const subVars = await variable.findAllByCssSelector('td'); - const selector = await subVars[0].findByTagName('a'); - const key = await selector.getVisibleText(); - const value = await subVars[1].getVisibleText(); - log.debug(`markdown table variables table is: ${key} ${value}`); - return { key, value, selector }; - }) - ); - return variablesKeyValueSelectorMap; - } + public async getMarkdownText(): Promise { + const el = await this.find.byCssSelector('.tvbVis'); + const text = await el.getVisibleText(); + return text; + } - /** - * return variable table message, if `table` is empty it will be fail - * - * **Note:** if `table` have variables, use `getMarkdownTableVariables` method instead - * @see {@link VisualBuilderPage#getMarkdownTableVariables} - * @returns - * @memberof VisualBuilderPage - */ - public async getMarkdownTableNoVariables() { - return await testSubjects.getVisibleText('tvbMarkdownEditor__noVariables'); - } + /** + * + * getting all markdown variables list which located on `table` section + * + * **Note**: if `table` not have variables, use `getMarkdownTableNoVariables` method instead + * @see {getMarkdownTableNoVariables} + * @returns {Promise>} + * @memberof VisualBuilderPage + */ + public async getMarkdownTableVariables(): Promise< + Array<{ key: string; value: string; selector: WebElementWrapper }> + > { + const testTableVariables = await this.testSubjects.find('tsvbMarkdownVariablesTable'); + const variablesSelector = 'tbody tr'; + const exists = await this.find.existsByCssSelector(variablesSelector); + if (!exists) { + this.log.debug('variable list is empty'); + return []; + } + const variables = await testTableVariables.findAllByCssSelector(variablesSelector); + + const variablesKeyValueSelectorMap = await Promise.all( + variables.map(async (variable) => { + const subVars = await variable.findAllByCssSelector('td'); + const selector = await subVars[0].findByTagName('a'); + const key = await selector.getVisibleText(); + const value = await subVars[1].getVisibleText(); + this.log.debug(`markdown table variables table is: ${key} ${value}`); + return { key, value, selector }; + }) + ); + return variablesKeyValueSelectorMap; + } - /** - * get all sub-tabs count for `time series`, `metric`, `top n`, `gauge`, `markdown` or `table` tab. - * - * @returns {Promise} - * @memberof VisualBuilderPage - */ - public async getSubTabs(): Promise { - return await find.allByCssSelector('[data-test-subj$="-subtab"]'); - } + /** + * return variable table message, if `table` is empty it will be fail + * + * **Note:** if `table` have variables, use `getMarkdownTableVariables` method instead + * @see {@link VisualBuilderPage#getMarkdownTableVariables} + * @returns + * @memberof VisualBuilderPage + */ + public async getMarkdownTableNoVariables() { + return await this.testSubjects.getVisibleText('tvbMarkdownEditor__noVariables'); + } - /** - * switch markdown sub-tab for visualization - * - * @param {'data' | 'options'| 'markdown'} subTab - * @memberof VisualBuilderPage - */ - public async markdownSwitchSubTab(subTab: 'data' | 'options' | 'markdown') { - const tab = await testSubjects.find(`${subTab}-subtab`); - const isSelected = await tab.getAttribute('aria-selected'); - if (isSelected !== 'true') { - await tab.click(); - } - } + /** + * get all sub-tabs count for `time series`, `metric`, `top n`, `gauge`, `markdown` or `table` tab. + * + * @returns {Promise} + * @memberof VisualBuilderPage + */ + public async getSubTabs(): Promise { + return await this.find.allByCssSelector('[data-test-subj$="-subtab"]'); + } - /** - * setting label for markdown visualization - * - * @param {string} variableName - * @param type - * @memberof VisualBuilderPage - */ - public async setMarkdownDataVariable(variableName: string, type: 'variable' | 'label') { - const SELECTOR = type === 'label' ? '[placeholder="Label"]' : '[placeholder="Variable name"]'; - if (variableName) { - await find.setValue(SELECTOR, variableName); - } else { - const input = await find.byCssSelector(SELECTOR); - await input.clearValueWithKeyboard({ charByChar: true }); - } + /** + * switch markdown sub-tab for visualization + * + * @param {'data' | 'options'| 'markdown'} subTab + * @memberof VisualBuilderPage + */ + public async markdownSwitchSubTab(subTab: 'data' | 'options' | 'markdown') { + const tab = await this.testSubjects.find(`${subTab}-subtab`); + const isSelected = await tab.getAttribute('aria-selected'); + if (isSelected !== 'true') { + await tab.click(); } + } - public async clickSeriesOption(nth = 0) { - const el = await testSubjects.findAll('seriesOptions'); - await el[nth].click(); + /** + * setting label for markdown visualization + * + * @param {string} variableName + * @param type + * @memberof VisualBuilderPage + */ + public async setMarkdownDataVariable(variableName: string, type: 'variable' | 'label') { + const SELECTOR = type === 'label' ? '[placeholder="Label"]' : '[placeholder="Variable name"]'; + if (variableName) { + await this.find.setValue(SELECTOR, variableName); + } else { + const input = await this.find.byCssSelector(SELECTOR); + await input.clearValueWithKeyboard({ charByChar: true }); } + } - public async clearOffsetSeries() { - const el = await testSubjects.find('offsetTimeSeries'); - await el.clearValue(); - } + public async clickSeriesOption(nth = 0) { + const el = await this.testSubjects.findAll('seriesOptions'); + await el[nth].click(); + } - public async toggleAutoApplyChanges() { - await find.clickByCssSelector('#tsvbAutoApplyInput'); - } + public async clearOffsetSeries() { + const el = await this.testSubjects.find('offsetTimeSeries'); + await el.clearValue(); + } - public async applyChanges() { - await testSubjects.clickWhenNotDisabled('applyBtn'); - } + public async toggleAutoApplyChanges() { + await this.find.clickByCssSelector('#tsvbAutoApplyInput'); + } - /** - * change the data formatter for template in an `options` label tab - * - * @param formatter - typeof formatter which you can use for presenting data. By default kibana show `Number` formatter - */ - public async changeDataFormatter( - formatter: 'Bytes' | 'Number' | 'Percent' | 'Duration' | 'Custom' - ) { - const formatterEl = await testSubjects.find('tsvbDataFormatPicker'); - await comboBox.setElement(formatterEl, formatter, { clickWithMouse: true }); - } + public async applyChanges() { + await this.testSubjects.clickWhenNotDisabled('applyBtn'); + } - /** - * set duration formatter additional settings - * - * @param from start format - * @param to end format - * @param decimalPlaces decimals count - */ - public async setDurationFormatterSettings({ - from, - to, - decimalPlaces, - }: { - from?: FromDuration; - to?: ToDuration; - decimalPlaces?: string; - }) { - if (from) { - await retry.try(async () => { - const fromCombobox = await find.byCssSelector('[id$="from-row"] .euiComboBox'); - await comboBox.setElement(fromCombobox, from, { clickWithMouse: true }); - }); - } - if (to) { - const toCombobox = await find.byCssSelector('[id$="to-row"] .euiComboBox'); - await comboBox.setElement(toCombobox, to, { clickWithMouse: true }); - } - if (decimalPlaces) { - const decimalPlacesInput = await find.byCssSelector('[id$="decimal"]'); - await decimalPlacesInput.type(decimalPlaces); - } - } + /** + * change the data formatter for template in an `options` label tab + * + * @param formatter - typeof formatter which you can use for presenting data. By default kibana show `Number` formatter + */ + public async changeDataFormatter( + formatter: 'Bytes' | 'Number' | 'Percent' | 'Duration' | 'Custom' + ) { + const formatterEl = await this.testSubjects.find('tsvbDataFormatPicker'); + await this.comboBox.setElement(formatterEl, formatter, { clickWithMouse: true }); + } - /** - * write template for aggregation row in the `option` tab - * - * @param template always should contain `{{value}}` - * @example - * await visualBuilder.enterSeriesTemplate('$ {{value}}') // add `$` symbol for value - */ - public async enterSeriesTemplate(template: string) { - const el = await testSubjects.find('tsvb_series_value'); - await el.clearValueWithKeyboard(); - await el.type(template); + /** + * set duration formatter additional settings + * + * @param from start format + * @param to end format + * @param decimalPlaces decimals count + */ + public async setDurationFormatterSettings({ + from, + to, + decimalPlaces, + }: { + from?: FromDuration; + to?: ToDuration; + decimalPlaces?: string; + }) { + if (from) { + await this.retry.try(async () => { + const fromCombobox = await this.find.byCssSelector('[id$="from-row"] .euiComboBox'); + await this.comboBox.setElement(fromCombobox, from, { clickWithMouse: true }); + }); } - - public async enterOffsetSeries(value: string) { - const el = await testSubjects.find('offsetTimeSeries'); - await el.clearValue(); - await el.type(value); + if (to) { + const toCombobox = await this.find.byCssSelector('[id$="to-row"] .euiComboBox'); + await this.comboBox.setElement(toCombobox, to, { clickWithMouse: true }); } - - public async getRhythmChartLegendValue(nth = 0) { - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const metricValue = ( - await find.allByCssSelector(`.echLegendItem .echLegendItem__extra`, 20000) - )[nth]; - await metricValue.moveMouseTo(); - return await metricValue.getVisibleText(); + if (decimalPlaces) { + const decimalPlacesInput = await this.find.byCssSelector('[id$="decimal"]'); + await decimalPlacesInput.type(decimalPlaces); } + } - public async clickGauge() { - await testSubjects.click('gaugeTsvbTypeBtn'); - } + /** + * write template for aggregation row in the `option` tab + * + * @param template always should contain `{{value}}` + * @example + * await visualBuilder.enterSeriesTemplate('$ {{value}}') // add `$` symbol for value + */ + public async enterSeriesTemplate(template: string) { + const el = await this.testSubjects.find('tsvb_series_value'); + await el.clearValueWithKeyboard(); + await el.type(template); + } - public async getGaugeLabel() { - const gaugeLabel = await find.byCssSelector('.tvbVisGauge__label'); - return await gaugeLabel.getVisibleText(); - } + public async enterOffsetSeries(value: string) { + const el = await this.testSubjects.find('offsetTimeSeries'); + await el.clearValue(); + await el.type(value); + } - public async getGaugeCount() { - const gaugeCount = await find.byCssSelector('.tvbVisGauge__value'); - return await gaugeCount.getVisibleText(); - } + public async getRhythmChartLegendValue(nth = 0) { + await this.visChart.waitForVisualizationRenderingStabilized(); + const metricValue = ( + await this.find.allByCssSelector(`.echLegendItem .echLegendItem__extra`, 20000) + )[nth]; + await metricValue.moveMouseTo(); + return await metricValue.getVisibleText(); + } - public async clickTopN() { - await testSubjects.click('top_nTsvbTypeBtn'); - } + public async clickGauge() { + await this.testSubjects.click('gaugeTsvbTypeBtn'); + } - public async getTopNLabel() { - const topNLabel = await find.byCssSelector('.tvbVisTopN__label'); - return await topNLabel.getVisibleText(); - } + public async getGaugeLabel() { + const gaugeLabel = await this.find.byCssSelector('.tvbVisGauge__label'); + return await gaugeLabel.getVisibleText(); + } - public async getTopNCount() { - const gaugeCount = await find.byCssSelector('.tvbVisTopN__value'); - return await gaugeCount.getVisibleText(); - } + public async getGaugeCount() { + const gaugeCount = await this.find.byCssSelector('.tvbVisGauge__value'); + return await gaugeCount.getVisibleText(); + } - public async clickTable() { - await testSubjects.click('tableTsvbTypeBtn'); - } + public async clickTopN() { + await this.testSubjects.click('top_nTsvbTypeBtn'); + } - public async createNewAgg(nth = 0) { - const prevAggs = await testSubjects.findAll('aggSelector'); - const elements = await testSubjects.findAll('addMetricAddBtn'); - await elements[nth].click(); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - await retry.waitFor('new agg is added', async () => { - const currentAggs = await testSubjects.findAll('aggSelector'); - return currentAggs.length > prevAggs.length; - }); - } + public async getTopNLabel() { + const topNLabel = await this.find.byCssSelector('.tvbVisTopN__label'); + return await topNLabel.getVisibleText(); + } - public async selectAggType(value: string, nth = 0) { - const elements = await testSubjects.findAll('aggSelector'); - await comboBox.setElement(elements[nth], value); - return await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async getTopNCount() { + const gaugeCount = await this.find.byCssSelector('.tvbVisTopN__value'); + return await gaugeCount.getVisibleText(); + } - public async fillInExpression(expression: string, nth = 0) { - const expressions = await testSubjects.findAll('mathExpression'); - await expressions[nth].type(expression); - return await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async clickTable() { + await this.testSubjects.click('tableTsvbTypeBtn'); + } - public async fillInVariable(name = 'test', metric = 'Count', nth = 0) { - const elements = await testSubjects.findAll('varRow'); - const varNameInput = await elements[nth].findByCssSelector('.tvbAggs__varName'); - await varNameInput.type(name); - const metricSelectWrapper = await elements[nth].findByCssSelector( - '.tvbAggs__varMetricWrapper' - ); - await comboBox.setElement(metricSelectWrapper, metric); - return await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async createNewAgg(nth = 0) { + const prevAggs = await this.testSubjects.findAll('aggSelector'); + const elements = await this.testSubjects.findAll('addMetricAddBtn'); + await elements[nth].click(); + await this.visChart.waitForVisualizationRenderingStabilized(); + await this.retry.waitFor('new agg is added', async () => { + const currentAggs = await this.testSubjects.findAll('aggSelector'); + return currentAggs.length > prevAggs.length; + }); + } - public async selectGroupByField(fieldName: string) { - await comboBox.set('groupByField', fieldName); - } + public async selectAggType(value: string, nth = 0) { + const elements = await this.testSubjects.findAll('aggSelector'); + await this.comboBox.setElement(elements[nth], value); + return await this.header.waitUntilLoadingHasFinished(); + } - public async setColumnLabelValue(value: string) { - const el = await testSubjects.find('columnLabelName'); - await el.clearValue(); - await el.type(value); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async fillInExpression(expression: string, nth = 0) { + const expressions = await this.testSubjects.findAll('mathExpression'); + await expressions[nth].type(expression); + return await this.header.waitUntilLoadingHasFinished(); + } - /** - * get values for rendered table - * - * **Note:** this work only for table visualization - * - * @returns {Promise} - * @memberof VisualBuilderPage - */ - public async getViewTable(): Promise { - const tableView = await testSubjects.find('tableView', 20000); - return await tableView.getVisibleText(); - } + public async fillInVariable(name = 'test', metric = 'Count', nth = 0) { + const elements = await this.testSubjects.findAll('varRow'); + const varNameInput = await elements[nth].findByCssSelector('.tvbAggs__varName'); + await varNameInput.type(name); + const metricSelectWrapper = await elements[nth].findByCssSelector('.tvbAggs__varMetricWrapper'); + await this.comboBox.setElement(metricSelectWrapper, metric); + return await this.header.waitUntilLoadingHasFinished(); + } - public async clickPanelOptions(tabName: string) { - await testSubjects.click(`${tabName}EditorPanelOptionsBtn`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async selectGroupByField(fieldName: string) { + await this.comboBox.set('groupByField', fieldName); + } - public async clickDataTab(tabName: string) { - await testSubjects.click(`${tabName}EditorDataBtn`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async setColumnLabelValue(value: string) { + const el = await this.testSubjects.find('columnLabelName'); + await el.clearValue(); + await el.type(value); + await this.header.waitUntilLoadingHasFinished(); + } - public async switchIndexPatternSelectionMode(useKibanaIndices: boolean) { - await testSubjects.click('switchIndexPatternSelectionModePopover'); - await testSubjects.setEuiSwitch( - 'switchIndexPatternSelectionMode', - useKibanaIndices ? 'check' : 'uncheck' - ); - } + /** + * get values for rendered table + * + * **Note:** this work only for table visualization + * + * @returns {Promise} + * @memberof VisualBuilderPage + */ + public async getViewTable(): Promise { + const tableView = await this.testSubjects.find('tableView', 20000); + return await tableView.getVisibleText(); + } - public async setIndexPatternValue(value: string, useKibanaIndices?: boolean) { - const metricsIndexPatternInput = 'metricsIndexPatternInput'; + public async clickPanelOptions(tabName: string) { + await this.testSubjects.click(`${tabName}EditorPanelOptionsBtn`); + await this.header.waitUntilLoadingHasFinished(); + } - if (useKibanaIndices !== undefined) { - await this.switchIndexPatternSelectionMode(useKibanaIndices); - } + public async clickDataTab(tabName: string) { + await this.testSubjects.click(`${tabName}EditorDataBtn`); + await this.header.waitUntilLoadingHasFinished(); + } - if (useKibanaIndices === false) { - const el = await testSubjects.find(metricsIndexPatternInput); - await el.clearValue(); - if (value) { - await el.type(value, { charByChar: true }); - } - } else { - await comboBox.clearInputField(metricsIndexPatternInput); - if (value) { - await comboBox.setCustom(metricsIndexPatternInput, value); - } - } + public async switchIndexPatternSelectionMode(useKibanaIndices: boolean) { + await this.testSubjects.click('switchIndexPatternSelectionModePopover'); + await this.testSubjects.setEuiSwitch( + 'switchIndexPatternSelectionMode', + useKibanaIndices ? 'check' : 'uncheck' + ); + } + + public async setIndexPatternValue(value: string, useKibanaIndices?: boolean) { + const metricsIndexPatternInput = 'metricsIndexPatternInput'; - await PageObjects.header.waitUntilLoadingHasFinished(); + if (useKibanaIndices !== undefined) { + await this.switchIndexPatternSelectionMode(useKibanaIndices); } - public async setIntervalValue(value: string) { - const el = await testSubjects.find('metricsIndexPatternInterval'); + if (useKibanaIndices === false) { + const el = await this.testSubjects.find(metricsIndexPatternInput); await el.clearValue(); - await el.type(value); - await PageObjects.header.waitUntilLoadingHasFinished(); + if (value) { + await el.type(value, { charByChar: true }); + } + } else { + await this.comboBox.clearInputField(metricsIndexPatternInput); + if (value) { + await this.comboBox.setCustom(metricsIndexPatternInput, value); + } } - public async setDropLastBucket(value: boolean) { - const option = await testSubjects.find(`metricsDropLastBucket-${value ? 'yes' : 'no'}`); - (await option.findByCssSelector('label')).click(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + await this.header.waitUntilLoadingHasFinished(); + } - public async waitForIndexPatternTimeFieldOptionsLoaded() { - await retry.waitFor('combobox options loaded', async () => { - const options = await comboBox.getOptions('metricsIndexPatternFieldsSelect'); - log.debug(`-- optionsCount=${options.length}`); - return options.length > 0; - }); - } + public async setIntervalValue(value: string) { + const el = await this.testSubjects.find('metricsIndexPatternInterval'); + await el.clearValue(); + await el.type(value); + await this.header.waitUntilLoadingHasFinished(); + } - public async selectIndexPatternTimeField(timeField: string) { - await retry.try(async () => { - await comboBox.clearInputField('metricsIndexPatternFieldsSelect'); - await comboBox.set('metricsIndexPatternFieldsSelect', timeField); - }); - } + public async setDropLastBucket(value: boolean) { + const option = await this.testSubjects.find(`metricsDropLastBucket-${value ? 'yes' : 'no'}`); + (await option.findByCssSelector('label')).click(); + await this.header.waitUntilLoadingHasFinished(); + } - /** - * check that table visualization is visible and ready for interact - * - * @returns {Promise} - * @memberof VisualBuilderPage - */ - public async checkTableTabIsPresent(): Promise { - await testSubjects.existOrFail('visualizationLoader'); - const isDataExists = await testSubjects.exists('tableView'); - log.debug(`data is already rendered: ${isDataExists}`); - if (!isDataExists) { - await this.checkPreviewIsDisabled(); - } - } + public async waitForIndexPatternTimeFieldOptionsLoaded() { + await this.retry.waitFor('combobox options loaded', async () => { + const options = await this.comboBox.getOptions('metricsIndexPatternFieldsSelect'); + this.log.debug(`-- optionsCount=${options.length}`); + return options.length > 0; + }); + } - /** - * set label name for aggregation - * - * @param {string} labelName - * @param {number} [nth=0] - * @memberof VisualBuilderPage - */ - public async setLabel(labelName: string, nth: number = 0): Promise { - const input = (await find.allByCssSelector('[placeholder="Label"]'))[nth]; - await input.type(labelName); - } + public async selectIndexPatternTimeField(timeField: string) { + await this.retry.try(async () => { + await this.comboBox.clearInputField('metricsIndexPatternFieldsSelect'); + await this.comboBox.set('metricsIndexPatternFieldsSelect', timeField); + }); + } - /** - * set field for type of aggregation - * - * @param {string} field name of field - * @param {number} [aggNth=0] number of aggregation. Start by zero - * @default 0 - * @memberof VisualBuilderPage - */ - public async setFieldForAggregation(field: string, aggNth: number = 0): Promise { - const fieldEl = await this.getFieldForAggregation(aggNth); - - await comboBox.setElement(fieldEl, field); + /** + * check that table visualization is visible and ready for interact + * + * @returns {Promise} + * @memberof VisualBuilderPage + */ + public async checkTableTabIsPresent(): Promise { + await this.testSubjects.existOrFail('visualizationLoader'); + const isDataExists = await this.testSubjects.exists('tableView'); + this.log.debug(`data is already rendered: ${isDataExists}`); + if (!isDataExists) { + await this.checkPreviewIsDisabled(); } + } - public async checkFieldForAggregationValidity(aggNth: number = 0): Promise { - const fieldEl = await this.getFieldForAggregation(aggNth); + /** + * set label name for aggregation + * + * @param {string} labelName + * @param {number} [nth=0] + * @memberof VisualBuilderPage + */ + public async setLabel(labelName: string, nth: number = 0): Promise { + const input = (await this.find.allByCssSelector('[placeholder="Label"]'))[nth]; + await input.type(labelName); + } - return await comboBox.checkValidity(fieldEl); - } + /** + * set field for type of aggregation + * + * @param {string} field name of field + * @param {number} [aggNth=0] number of aggregation. Start by zero + * @default 0 + * @memberof VisualBuilderPage + */ + public async setFieldForAggregation(field: string, aggNth: number = 0): Promise { + const fieldEl = await this.getFieldForAggregation(aggNth); + + await this.comboBox.setElement(fieldEl, field); + } - public async getFieldForAggregation(aggNth: number = 0): Promise { - const labels = await testSubjects.findAll('aggRow'); - const label = labels[aggNth]; + public async checkFieldForAggregationValidity(aggNth: number = 0): Promise { + const fieldEl = await this.getFieldForAggregation(aggNth); - return (await label.findAllByTestSubject('comboBoxInput'))[1]; - } + return await this.comboBox.checkValidity(fieldEl); + } - public async clickColorPicker(): Promise { - const picker = await find.byCssSelector('.tvbColorPicker button'); - await picker.clickMouseButton(); - } + public async getFieldForAggregation(aggNth: number = 0): Promise { + const labels = await this.testSubjects.findAll('aggRow'); + const label = labels[aggNth]; - public async setBackgroundColor(colorHex: string): Promise { - await this.clickColorPicker(); - await this.checkColorPickerPopUpIsPresent(); - await find.setValue('.euiColorPicker input', colorHex); - await this.clickColorPicker(); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - } + return (await label.findAllByTestSubject('comboBoxInput'))[1]; + } - public async checkColorPickerPopUpIsPresent(): Promise { - log.debug(`Check color picker popup is present`); - await testSubjects.existOrFail('colorPickerPopover', { timeout: 5000 }); - } + public async clickColorPicker(): Promise { + const picker = await this.find.byCssSelector('.tvbColorPicker button'); + await picker.clickMouseButton(); + } - public async changePanelPreview(nth: number = 0): Promise { - const prevRenderingCount = await PageObjects.visChart.getVisualizationRenderingCount(); - const changePreviewBtnArray = await testSubjects.findAll('AddActivatePanelBtn'); - await changePreviewBtnArray[nth].click(); - await PageObjects.visChart.waitForRenderingCount(prevRenderingCount + 1); - } + public async setBackgroundColor(colorHex: string): Promise { + await this.clickColorPicker(); + await this.checkColorPickerPopUpIsPresent(); + await this.find.setValue('.euiColorPicker input', colorHex); + await this.clickColorPicker(); + await this.visChart.waitForVisualizationRenderingStabilized(); + } - public async checkPreviewIsDisabled(): Promise { - log.debug(`Check no data message is present`); - await testSubjects.existOrFail('timeseriesVis > visNoResult', { timeout: 5000 }); - } + public async checkColorPickerPopUpIsPresent(): Promise { + this.log.debug(`Check color picker popup is present`); + await this.testSubjects.existOrFail('colorPickerPopover', { timeout: 5000 }); + } - public async cloneSeries(nth: number = 0): Promise { - const cloneBtnArray = await testSubjects.findAll('AddCloneBtn'); - await cloneBtnArray[nth].click(); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - } + public async changePanelPreview(nth: number = 0): Promise { + const prevRenderingCount = await this.visChart.getVisualizationRenderingCount(); + const changePreviewBtnArray = await this.testSubjects.findAll('AddActivatePanelBtn'); + await changePreviewBtnArray[nth].click(); + await this.visChart.waitForRenderingCount(prevRenderingCount + 1); + } - /** - * Get aggregation count for the current series - * - * @param {number} [nth=0] series - * @returns {Promise} - * @memberof VisualBuilderPage - */ - public async getAggregationCount(nth: number = 0): Promise { - const series = await this.getSeries(); - const aggregation = await series[nth].findAllByTestSubject('draggable'); - return aggregation.length; - } + public async checkPreviewIsDisabled(): Promise { + this.log.debug(`Check no data message is present`); + await this.testSubjects.existOrFail('timeseriesVis > visNoResult', { timeout: 5000 }); + } - public async deleteSeries(nth: number = 0): Promise { - const prevRenderingCount = await PageObjects.visChart.getVisualizationRenderingCount(); - const cloneBtnArray = await testSubjects.findAll('AddDeleteBtn'); - await cloneBtnArray[nth].click(); - await PageObjects.visChart.waitForRenderingCount(prevRenderingCount + 1); - } + public async cloneSeries(nth: number = 0): Promise { + const cloneBtnArray = await this.testSubjects.findAll('AddCloneBtn'); + await cloneBtnArray[nth].click(); + await this.visChart.waitForVisualizationRenderingStabilized(); + } - public async getLegendItems(): Promise { - return await find.allByCssSelector('.echLegendItem'); - } + /** + * Get aggregation count for the current series + * + * @param {number} [nth=0] series + * @returns {Promise} + * @memberof VisualBuilderPage + */ + public async getAggregationCount(nth: number = 0): Promise { + const series = await this.getSeries(); + const aggregation = await series[nth].findAllByTestSubject('draggable'); + return aggregation.length; + } - public async getLegendItemsContent(): Promise { - const legendList = await find.byCssSelector('.echLegendList'); - const $ = await legendList.parseDomContent(); + public async deleteSeries(nth: number = 0): Promise { + const prevRenderingCount = await this.visChart.getVisualizationRenderingCount(); + const cloneBtnArray = await this.testSubjects.findAll('AddDeleteBtn'); + await cloneBtnArray[nth].click(); + await this.visChart.waitForRenderingCount(prevRenderingCount + 1); + } - return $('li') - .toArray() - .map((li) => { - const label = $(li).find('.echLegendItem__label').text(); - const value = $(li).find('.echLegendItem__extra').text(); + public async getLegendItems(): Promise { + return await this.find.allByCssSelector('.echLegendItem'); + } - return `${label}: ${value}`; - }); - } + public async getLegendItemsContent(): Promise { + const legendList = await this.find.byCssSelector('.echLegendList'); + const $ = await legendList.parseDomContent(); - public async getSeries(): Promise { - return await find.allByCssSelector('.tvbSeriesEditor'); - } + return $('li') + .toArray() + .map((li) => { + const label = $(li).find('.echLegendItem__label').text(); + const value = $(li).find('.echLegendItem__extra').text(); - public async setMetricsGroupByTerms(field: string) { - const groupBy = await find.byCssSelector( - '.tvbAggRow--split [data-test-subj="comboBoxInput"]' - ); - await comboBox.setElement(groupBy, 'Terms', { clickWithMouse: true }); - await PageObjects.common.sleep(1000); - const byField = await testSubjects.find('groupByField'); - await comboBox.setElement(byField, field); - } + return `${label}: ${value}`; + }); + } - public async checkSelectedMetricsGroupByValue(value: string) { - const groupBy = await find.byCssSelector( - '.tvbAggRow--split [data-test-subj="comboBoxInput"]' - ); - return await comboBox.isOptionSelected(groupBy, value); - } + public async getSeries(): Promise { + return await this.find.allByCssSelector('.tvbSeriesEditor'); + } - public async setMetricsDataTimerangeMode(value: string) { - const dataTimeRangeMode = await testSubjects.find('dataTimeRangeMode'); - return await comboBox.setElement(dataTimeRangeMode, value); - } + public async setMetricsGroupByTerms(field: string) { + const groupBy = await this.find.byCssSelector( + '.tvbAggRow--split [data-test-subj="comboBoxInput"]' + ); + await this.comboBox.setElement(groupBy, 'Terms', { clickWithMouse: true }); + await this.common.sleep(1000); + const byField = await this.testSubjects.find('groupByField'); + await this.comboBox.setElement(byField, field); + } - public async checkSelectedDataTimerangeMode(value: string) { - const dataTimeRangeMode = await testSubjects.find('dataTimeRangeMode'); - return await comboBox.isOptionSelected(dataTimeRangeMode, value); - } + public async checkSelectedMetricsGroupByValue(value: string) { + const groupBy = await this.find.byCssSelector( + '.tvbAggRow--split [data-test-subj="comboBoxInput"]' + ); + return await this.comboBox.isOptionSelected(groupBy, value); } - return new VisualBuilderPage(); + public async setMetricsDataTimerangeMode(value: string) { + const dataTimeRangeMode = await this.testSubjects.find('dataTimeRangeMode'); + return await this.comboBox.setElement(dataTimeRangeMode, value); + } + + public async checkSelectedDataTimerangeMode(value: string) { + const dataTimeRangeMode = await this.testSubjects.find('dataTimeRangeMode'); + return await this.comboBox.isOptionSelected(dataTimeRangeMode, value); + } } diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 7ecf800b4be7c0..c8587f4ffd3469 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -9,614 +9,618 @@ import { Position } from '@elastic/charts'; import Color from 'color'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; const xyChartSelector = 'visTypeXyChart'; const pieChartSelector = 'visTypePieChart'; -export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const config = getService('config'); - const find = getService('find'); - const log = getService('log'); - const retry = getService('retry'); - const kibanaServer = getService('kibanaServer'); - const elasticChart = getService('elasticChart'); - const dataGrid = getService('dataGrid'); - const defaultFindTimeout = config.get('timeouts.find'); - const { common } = getPageObjects(['common']); - - class VisualizeChart { - public async getEsChartDebugState(chartSelector: string) { - return await elasticChart.getChartDebugData(chartSelector); - } +export class VisualizeChartPageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly config = this.ctx.getService('config'); + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly kibanaServer = this.ctx.getService('kibanaServer'); + private readonly elasticChart = this.ctx.getService('elasticChart'); + private readonly dataGrid = this.ctx.getService('dataGrid'); + private readonly common = this.ctx.getPageObject('common'); + + private readonly defaultFindTimeout = this.config.get('timeouts.find'); + + public async getEsChartDebugState(chartSelector: string) { + return await this.elasticChart.getChartDebugData(chartSelector); + } - /** - * Is new charts library advanced setting enabled - */ - public async isNewChartsLibraryEnabled(): Promise { - const legacyChartsLibrary = - Boolean(await kibanaServer.uiSettings.get('visualization:visualize:legacyChartsLibrary')) ?? - true; - const enabled = !legacyChartsLibrary; - log.debug(`-- isNewChartsLibraryEnabled = ${enabled}`); - - return enabled; - } + /** + * Is new charts library advanced setting enabled + */ + public async isNewChartsLibraryEnabled(): Promise { + const legacyChartsLibrary = + Boolean( + await this.kibanaServer.uiSettings.get('visualization:visualize:legacyChartsLibrary') + ) ?? true; + const enabled = !legacyChartsLibrary; + this.log.debug(`-- isNewChartsLibraryEnabled = ${enabled}`); + + return enabled; + } - /** - * Is new charts library enabled and an area, line or histogram chart exists - */ - public async isNewLibraryChart(chartSelector: string): Promise { - const enabled = await this.isNewChartsLibraryEnabled(); + /** + * Is new charts library enabled and an area, line or histogram chart exists + */ + public async isNewLibraryChart(chartSelector: string): Promise { + const enabled = await this.isNewChartsLibraryEnabled(); - if (!enabled) { - log.debug(`-- isNewLibraryChart = false`); - return false; - } - - // check if enabled but not a line, area, histogram or pie chart - if (await find.existsByCssSelector('.visLib__chart', 1)) { - const chart = await find.byCssSelector('.visLib__chart'); - const chartType = await chart.getAttribute('data-vislib-chart-type'); + if (!enabled) { + this.log.debug(`-- isNewLibraryChart = false`); + return false; + } - if (!['line', 'area', 'histogram', 'pie'].includes(chartType)) { - log.debug(`-- isNewLibraryChart = false`); - return false; - } - } + // check if enabled but not a line, area, histogram or pie chart + if (await this.find.existsByCssSelector('.visLib__chart', 1)) { + const chart = await this.find.byCssSelector('.visLib__chart'); + const chartType = await chart.getAttribute('data-vislib-chart-type'); - if (!(await elasticChart.hasChart(chartSelector, 1))) { - // not be a vislib chart type - log.debug(`-- isNewLibraryChart = false`); + if (!['line', 'area', 'histogram', 'pie'].includes(chartType)) { + this.log.debug(`-- isNewLibraryChart = false`); return false; } + } - log.debug(`-- isNewLibraryChart = true`); - return true; + if (!(await this.elasticChart.hasChart(chartSelector, 1))) { + // not be a vislib chart type + this.log.debug(`-- isNewLibraryChart = false`); + return false; } - /** - * Helper method to get expected values that are slightly different - * between vislib and elastic-chart inplementations - * @param vislibValue value expected for vislib chart - * @param elasticChartsValue value expected for `@elastic/charts` chart - */ - public async getExpectedValue(vislibValue: T, elasticChartsValue: T): Promise { - if (await this.isNewLibraryChart(xyChartSelector)) { - return elasticChartsValue; - } + this.log.debug(`-- isNewLibraryChart = true`); + return true; + } - return vislibValue; + /** + * Helper method to get expected values that are slightly different + * between vislib and elastic-chart inplementations + * @param vislibValue value expected for vislib chart + * @param elasticChartsValue value expected for `@elastic/charts` chart + */ + public async getExpectedValue(vislibValue: T, elasticChartsValue: T): Promise { + if (await this.isNewLibraryChart(xyChartSelector)) { + return elasticChartsValue; } - public async getYAxisTitle() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const xAxis = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; - return xAxis[0]?.title; - } + return vislibValue; + } - const title = await find.byCssSelector('.y-axis-div .y-axis-title text'); - return await title.getVisibleText(); + public async getYAxisTitle() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const xAxis = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; + return xAxis[0]?.title; } - public async getXAxisLabels() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const [xAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.x ?? []; - return xAxis?.labels; - } + const title = await this.find.byCssSelector('.y-axis-div .y-axis-title text'); + return await title.getVisibleText(); + } - const xAxis = await find.byCssSelector('.visAxis--x.visAxis__column--bottom'); - const $ = await xAxis.parseDomContent(); - return $('.x > g > text') - .toArray() - .map((tick) => $(tick).text().trim()); + public async getXAxisLabels() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const [xAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.x ?? []; + return xAxis?.labels; } - public async getYAxisLabels() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; - return yAxis?.labels; - } + const xAxis = await this.find.byCssSelector('.visAxis--x.visAxis__column--bottom'); + const $ = await xAxis.parseDomContent(); + return $('.x > g > text') + .toArray() + .map((tick) => $(tick).text().trim()); + } - const yAxis = await find.byCssSelector('.visAxis__column--y.visAxis__column--left'); - const $ = await yAxis.parseDomContent(); - return $('.y > g > text') - .toArray() - .map((tick) => $(tick).text().trim()); + public async getYAxisLabels() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; + return yAxis?.labels; } - public async getYAxisLabelsAsNumbers() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; - return yAxis?.values; - } + const yAxis = await this.find.byCssSelector('.visAxis__column--y.visAxis__column--left'); + const $ = await yAxis.parseDomContent(); + return $('.y > g > text') + .toArray() + .map((tick) => $(tick).text().trim()); + } - return (await this.getYAxisLabels()).map((label) => Number(label.replace(',', ''))); + public async getYAxisLabelsAsNumbers() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const [yAxis] = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; + return yAxis?.values; } - /** - * Gets the chart data and scales it based on chart height and label. - * @param dataLabel data-label value - * @param axis axis value, 'ValueAxis-1' by default - * - * Returns an array of height values - */ - public async getAreaChartData(dataLabel: string, axis = 'ValueAxis-1') { - if (await this.isNewLibraryChart(xyChartSelector)) { - const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; - const points = areas.find(({ name }) => name === dataLabel)?.lines.y1.points ?? []; - return points.map(({ y }) => y); - } - - const yAxisRatio = await this.getChartYAxisRatio(axis); + return (await this.getYAxisLabels()).map((label) => Number(label.replace(',', ''))); + } - const rectangle = await find.byCssSelector('rect.background'); - const yAxisHeight = Number(await rectangle.getAttribute('height')); - log.debug(`height --------- ${yAxisHeight}`); + /** + * Gets the chart data and scales it based on chart height and label. + * @param dataLabel data-label value + * @param axis axis value, 'ValueAxis-1' by default + * + * Returns an array of height values + */ + public async getAreaChartData(dataLabel: string, axis = 'ValueAxis-1') { + if (await this.isNewLibraryChart(xyChartSelector)) { + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; + const points = areas.find(({ name }) => name === dataLabel)?.lines.y1.points ?? []; + return points.map(({ y }) => y); + } + + const yAxisRatio = await this.getChartYAxisRatio(axis); + + const rectangle = await this.find.byCssSelector('rect.background'); + const yAxisHeight = Number(await rectangle.getAttribute('height')); + this.log.debug(`height --------- ${yAxisHeight}`); + + const path = await this.retry.try( + async () => + await this.find.byCssSelector( + `path[data-label="${dataLabel}"]`, + this.defaultFindTimeout * 2 + ) + ); + const data = await path.getAttribute('d'); + this.log.debug(data); + // This area chart data starts with a 'M'ove to a x,y location, followed + // by a bunch of 'L'ines from that point to the next. Those points are + // the values we're going to use to calculate the data values we're testing. + // So git rid of the one 'M' and split the rest on the 'L's. + const tempArray = data + .replace('M ', '') + .replace('M', '') + .replace(/ L /g, 'L') + .replace(/ /g, ',') + .split('L'); + const chartSections = tempArray.length / 2; + const chartData = []; + for (let i = 0; i < chartSections; i++) { + chartData[i] = Math.round((yAxisHeight - Number(tempArray[i].split(',')[1])) * yAxisRatio); + this.log.debug('chartData[i] =' + chartData[i]); + } + return chartData; + } - const path = await retry.try( - async () => - await find.byCssSelector(`path[data-label="${dataLabel}"]`, defaultFindTimeout * 2) - ); - const data = await path.getAttribute('d'); - log.debug(data); - // This area chart data starts with a 'M'ove to a x,y location, followed - // by a bunch of 'L'ines from that point to the next. Those points are - // the values we're going to use to calculate the data values we're testing. - // So git rid of the one 'M' and split the rest on the 'L's. - const tempArray = data - .replace('M ', '') - .replace('M', '') - .replace(/ L /g, 'L') - .replace(/ /g, ',') - .split('L'); - const chartSections = tempArray.length / 2; - const chartData = []; - for (let i = 0; i < chartSections; i++) { - chartData[i] = Math.round((yAxisHeight - Number(tempArray[i].split(',')[1])) * yAxisRatio); - log.debug('chartData[i] =' + chartData[i]); - } - return chartData; - } + /** + * Returns the paths that compose an area chart. + * @param dataLabel data-label value + */ + public async getAreaChartPaths(dataLabel: string) { + if (await this.isNewLibraryChart(xyChartSelector)) { + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; + const path = areas.find(({ name }) => name === dataLabel)?.path ?? ''; + return path.split('L'); + } + + const path = await this.retry.try( + async () => + await this.find.byCssSelector( + `path[data-label="${dataLabel}"]`, + this.defaultFindTimeout * 2 + ) + ); + const data = await path.getAttribute('d'); + this.log.debug(data); + // This area chart data starts with a 'M'ove to a x,y location, followed + // by a bunch of 'L'ines from that point to the next. Those points are + // the values we're going to use to calculate the data values we're testing. + // So git rid of the one 'M' and split the rest on the 'L's. + return data.split('L'); + } - /** - * Returns the paths that compose an area chart. - * @param dataLabel data-label value - */ - public async getAreaChartPaths(dataLabel: string) { - if (await this.isNewLibraryChart(xyChartSelector)) { - const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; - const path = areas.find(({ name }) => name === dataLabel)?.path ?? ''; - return path.split('L'); - } + /** + * Gets the dots and normalizes their height. + * @param dataLabel data-label value + * @param axis axis value, 'ValueAxis-1' by default + */ + public async getLineChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { + if (await this.isNewLibraryChart(xyChartSelector)) { + // For now lines are rendered as areas to enable stacking + const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; + const lines = areas.map(({ lines: { y1 }, name, color }) => ({ ...y1, name, color })); + const points = lines.find(({ name }) => name === dataLabel)?.points ?? []; + return points.map(({ y }) => y); + } + + // 1). get the range/pixel ratio + const yAxisRatio = await this.getChartYAxisRatio(axis); + // 2). find and save the y-axis pixel size (the chart height) + const rectangle = await this.find.byCssSelector('clipPath rect'); + const yAxisHeight = Number(await rectangle.getAttribute('height')); + // 3). get the visWrapper__chart elements + const chartTypes = await this.retry.try( + async () => + await this.find.allByCssSelector( + `.visWrapper__chart circle[data-label="${dataLabel}"][fill-opacity="1"]`, + this.defaultFindTimeout * 2 + ) + ); + // 4). for each chart element, find the green circle, then the cy position + const chartData = await Promise.all( + chartTypes.map(async (chart) => { + const cy = Number(await chart.getAttribute('cy')); + // the point_series_options test has data in the billions range and + // getting 11 digits of precision with these calculations is very hard + return Math.round(Number(((yAxisHeight - cy) * yAxisRatio).toPrecision(6))); + }) + ); + + return chartData; + } - const path = await retry.try( - async () => - await find.byCssSelector(`path[data-label="${dataLabel}"]`, defaultFindTimeout * 2) - ); - const data = await path.getAttribute('d'); - log.debug(data); - // This area chart data starts with a 'M'ove to a x,y location, followed - // by a bunch of 'L'ines from that point to the next. Those points are - // the values we're going to use to calculate the data values we're testing. - // So git rid of the one 'M' and split the rest on the 'L's. - return data.split('L'); - } + /** + * Returns bar chart data in pixels + * @param dataLabel data-label value + * @param axis axis value, 'ValueAxis-1' by default + */ + public async getBarChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { + if (await this.isNewLibraryChart(xyChartSelector)) { + const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; + const values = bars.find(({ name }) => name === dataLabel)?.bars ?? []; + return values.map(({ y }) => y); + } + + const yAxisRatio = await this.getChartYAxisRatio(axis); + const svg = await this.find.byCssSelector('div.chart'); + const $ = await svg.parseDomContent(); + const chartData = $(`g > g.series > rect[data-label="${dataLabel}"]`) + .toArray() + .map((chart) => { + const barHeight = Number($(chart).attr('height')); + return Math.round(barHeight * yAxisRatio); + }); - /** - * Gets the dots and normalizes their height. - * @param dataLabel data-label value - * @param axis axis value, 'ValueAxis-1' by default - */ - public async getLineChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { - if (await this.isNewLibraryChart(xyChartSelector)) { - // For now lines are rendered as areas to enable stacking - const areas = (await this.getEsChartDebugState(xyChartSelector))?.areas ?? []; - const lines = areas.map(({ lines: { y1 }, name, color }) => ({ ...y1, name, color })); - const points = lines.find(({ name }) => name === dataLabel)?.points ?? []; - return points.map(({ y }) => y); - } + return chartData; + } - // 1). get the range/pixel ratio - const yAxisRatio = await this.getChartYAxisRatio(axis); - // 2). find and save the y-axis pixel size (the chart height) - const rectangle = await find.byCssSelector('clipPath rect'); - const yAxisHeight = Number(await rectangle.getAttribute('height')); - // 3). get the visWrapper__chart elements - const chartTypes = await retry.try( - async () => - await find.allByCssSelector( - `.visWrapper__chart circle[data-label="${dataLabel}"][fill-opacity="1"]`, - defaultFindTimeout * 2 - ) - ); - // 4). for each chart element, find the green circle, then the cy position - const chartData = await Promise.all( - chartTypes.map(async (chart) => { - const cy = Number(await chart.getAttribute('cy')); - // the point_series_options test has data in the billions range and - // getting 11 digits of precision with these calculations is very hard - return Math.round(Number(((yAxisHeight - cy) * yAxisRatio).toPrecision(6))); - }) - ); + /** + * Returns the range/pixel ratio + * @param axis axis value, 'ValueAxis-1' by default + */ + private async getChartYAxisRatio(axis = 'ValueAxis-1') { + // 1). get the maximum chart Y-Axis marker value and Y position + const maxYAxisChartMarker = await this.retry.try( + async () => + await this.find.byCssSelector( + `div.visAxis__splitAxes--y > div > svg > g.${axis} > g:last-of-type.tick` + ) + ); + const maxYLabel = (await maxYAxisChartMarker.getVisibleText()).replace(/,/g, ''); + const maxYLabelYPosition = (await maxYAxisChartMarker.getPosition()).y; + this.log.debug(`maxYLabel = ${maxYLabel}, maxYLabelYPosition = ${maxYLabelYPosition}`); + + // 2). get the minimum chart Y-Axis marker value and Y position + const minYAxisChartMarker = await this.find.byCssSelector( + 'div.visAxis__column--y.visAxis__column--left > div > div > svg:nth-child(2) > g > g:nth-child(1).tick' + ); + const minYLabel = (await minYAxisChartMarker.getVisibleText()).replace(',', ''); + const minYLabelYPosition = (await minYAxisChartMarker.getPosition()).y; + return (Number(maxYLabel) - Number(minYLabel)) / (minYLabelYPosition - maxYLabelYPosition); + } - return chartData; - } + public async toggleLegend(show = true) { + const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + const legendSelector = isVisTypeXYChart || isVisTypePieChart ? '.echLegend' : '.visLegend'; - /** - * Returns bar chart data in pixels - * @param dataLabel data-label value - * @param axis axis value, 'ValueAxis-1' by default - */ - public async getBarChartData(dataLabel = 'Count', axis = 'ValueAxis-1') { - if (await this.isNewLibraryChart(xyChartSelector)) { - const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; - const values = bars.find(({ name }) => name === dataLabel)?.bars ?? []; - return values.map(({ y }) => y); + await this.retry.try(async () => { + const isVisible = await this.find.existsByCssSelector(legendSelector); + if ((show && !isVisible) || (!show && isVisible)) { + await this.testSubjects.click('vislibToggleLegend'); } + }); + } - const yAxisRatio = await this.getChartYAxisRatio(axis); - const svg = await find.byCssSelector('div.chart'); - const $ = await svg.parseDomContent(); - const chartData = $(`g > g.series > rect[data-label="${dataLabel}"]`) - .toArray() - .map((chart) => { - const barHeight = Number($(chart).attr('height')); - return Math.round(barHeight * yAxisRatio); - }); - - return chartData; - } - - /** - * Returns the range/pixel ratio - * @param axis axis value, 'ValueAxis-1' by default - */ - private async getChartYAxisRatio(axis = 'ValueAxis-1') { - // 1). get the maximum chart Y-Axis marker value and Y position - const maxYAxisChartMarker = await retry.try( - async () => - await find.byCssSelector( - `div.visAxis__splitAxes--y > div > svg > g.${axis} > g:last-of-type.tick` - ) - ); - const maxYLabel = (await maxYAxisChartMarker.getVisibleText()).replace(/,/g, ''); - const maxYLabelYPosition = (await maxYAxisChartMarker.getPosition()).y; - log.debug(`maxYLabel = ${maxYLabel}, maxYLabelYPosition = ${maxYLabelYPosition}`); + public async filterLegend(name: string) { + await this.toggleLegend(); + await this.testSubjects.click(`legend-${name}`); + const filterIn = await this.testSubjects.find(`legend-${name}-filterIn`); + await filterIn.click(); + await this.waitForVisualizationRenderingStabilized(); + } - // 2). get the minimum chart Y-Axis marker value and Y position - const minYAxisChartMarker = await find.byCssSelector( - 'div.visAxis__column--y.visAxis__column--left > div > div > svg:nth-child(2) > g > g:nth-child(1).tick' - ); - const minYLabel = (await minYAxisChartMarker.getVisibleText()).replace(',', ''); - const minYLabelYPosition = (await minYAxisChartMarker.getPosition()).y; - return (Number(maxYLabel) - Number(minYLabel)) / (minYLabelYPosition - maxYLabelYPosition); - } + public async doesLegendColorChoiceExist(color: string) { + return await this.testSubjects.exists(`visColorPickerColor-${color}`); + } - public async toggleLegend(show = true) { - const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); - const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); - const legendSelector = isVisTypeXYChart || isVisTypePieChart ? '.echLegend' : '.visLegend'; + public async selectNewLegendColorChoice(color: string) { + await this.testSubjects.click(`visColorPickerColor-${color}`); + } - await retry.try(async () => { - const isVisible = await find.existsByCssSelector(legendSelector); - if ((show && !isVisible) || (!show && isVisible)) { - await testSubjects.click('vislibToggleLegend'); - } - }); + public async doesSelectedLegendColorExist(color: string) { + if (await this.isNewLibraryChart(xyChartSelector)) { + const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; + return items.some(({ color: c }) => c === color); } - public async filterLegend(name: string) { - await this.toggleLegend(); - await testSubjects.click(`legend-${name}`); - const filterIn = await testSubjects.find(`legend-${name}-filterIn`); - await filterIn.click(); - await this.waitForVisualizationRenderingStabilized(); + if (await this.isNewLibraryChart(pieChartSelector)) { + const slices = + (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + return slices.some(({ color: c }) => { + const rgbColor = new Color(color).rgb().toString(); + return c === rgbColor; + }); } - public async doesLegendColorChoiceExist(color: string) { - return await testSubjects.exists(`visColorPickerColor-${color}`); - } + return await this.testSubjects.exists(`legendSelectedColor-${color}`); + } - public async selectNewLegendColorChoice(color: string) { - await testSubjects.click(`visColorPickerColor-${color}`); + public async expectError() { + if (!this.isNewLibraryChart(xyChartSelector)) { + await this.testSubjects.existOrFail('vislibVisualizeError'); } + } - public async doesSelectedLegendColorExist(color: string) { - if (await this.isNewLibraryChart(xyChartSelector)) { - const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; - return items.some(({ color: c }) => c === color); - } - - if (await this.isNewLibraryChart(pieChartSelector)) { - const slices = - (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; - return slices.some(({ color: c }) => { - const rgbColor = new Color(color).rgb().toString(); - return c === rgbColor; - }); - } - - return await testSubjects.exists(`legendSelectedColor-${color}`); - } + public async getVisualizationRenderingCount() { + const visualizationLoader = await this.testSubjects.find('visualizationLoader'); + const renderingCount = await visualizationLoader.getAttribute('data-rendering-count'); + return Number(renderingCount); + } - public async expectError() { - if (!this.isNewLibraryChart(xyChartSelector)) { - await testSubjects.existOrFail('vislibVisualizeError'); + public async waitForRenderingCount(minimumCount = 1) { + await this.retry.waitFor( + `rendering count to be greater than or equal to [${minimumCount}]`, + async () => { + const currentRenderingCount = await this.getVisualizationRenderingCount(); + this.log.debug(`-- currentRenderingCount=${currentRenderingCount}`); + this.log.debug(`-- expectedCount=${minimumCount}`); + return currentRenderingCount >= minimumCount; } - } + ); + } - public async getVisualizationRenderingCount() { - const visualizationLoader = await testSubjects.find('visualizationLoader'); - const renderingCount = await visualizationLoader.getAttribute('data-rendering-count'); - return Number(renderingCount); - } + public async waitForVisualizationRenderingStabilized() { + // assuming rendering is done when data-rendering-count is constant within 1000 ms + await this.retry.waitFor('rendering count to stabilize', async () => { + const firstCount = await this.getVisualizationRenderingCount(); + this.log.debug(`-- firstCount=${firstCount}`); - public async waitForRenderingCount(minimumCount = 1) { - await retry.waitFor( - `rendering count to be greater than or equal to [${minimumCount}]`, - async () => { - const currentRenderingCount = await this.getVisualizationRenderingCount(); - log.debug(`-- currentRenderingCount=${currentRenderingCount}`); - log.debug(`-- expectedCount=${minimumCount}`); - return currentRenderingCount >= minimumCount; - } - ); - } + await this.common.sleep(2000); - public async waitForVisualizationRenderingStabilized() { - // assuming rendering is done when data-rendering-count is constant within 1000 ms - await retry.waitFor('rendering count to stabilize', async () => { - const firstCount = await this.getVisualizationRenderingCount(); - log.debug(`-- firstCount=${firstCount}`); + const secondCount = await this.getVisualizationRenderingCount(); + this.log.debug(`-- secondCount=${secondCount}`); - await common.sleep(2000); + return firstCount === secondCount; + }); + } - const secondCount = await this.getVisualizationRenderingCount(); - log.debug(`-- secondCount=${secondCount}`); + public async waitForVisualization() { + await this.waitForVisualizationRenderingStabilized(); - return firstCount === secondCount; - }); + if (!(await this.isNewLibraryChart(xyChartSelector))) { + await this.find.byCssSelector('.visualization'); } + } - public async waitForVisualization() { - await this.waitForVisualizationRenderingStabilized(); - - if (!(await this.isNewLibraryChart(xyChartSelector))) { - await find.byCssSelector('.visualization'); - } + public async getLegendEntries() { + const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + if (isVisTypeXYChart) { + const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; + return items.map(({ name }) => name); } - public async getLegendEntries() { - const isVisTypeXYChart = await this.isNewLibraryChart(xyChartSelector); - const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); - if (isVisTypeXYChart) { - const items = (await this.getEsChartDebugState(xyChartSelector))?.legend?.items ?? []; - return items.map(({ name }) => name); - } - - if (isVisTypePieChart) { - const slices = - (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; - return slices.map(({ name }) => name); - } - - const legendEntries = await find.allByCssSelector( - '.visLegend__button', - defaultFindTimeout * 2 - ); - return await Promise.all( - legendEntries.map(async (chart) => await chart.getAttribute('data-label')) - ); + if (isVisTypePieChart) { + const slices = + (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + return slices.map(({ name }) => name); } - public async openLegendOptionColors(name: string, chartSelector: string) { - await this.waitForVisualizationRenderingStabilized(); - await retry.try(async () => { - if ( - (await this.isNewLibraryChart(xyChartSelector)) || - (await this.isNewLibraryChart(pieChartSelector)) - ) { - const chart = await find.byCssSelector(chartSelector); - const legendItemColor = await chart.findByCssSelector( - `[data-ech-series-name="${name}"] .echLegendItem__color` - ); - legendItemColor.click(); - } else { - // This click has been flaky in opening the legend, hence the retry. See - // https://github.com/elastic/kibana/issues/17468 - await testSubjects.click(`legend-${name}`); - } - - await this.waitForVisualizationRenderingStabilized(); - // arbitrary color chosen, any available would do - const arbitraryColor = (await this.isNewLibraryChart(xyChartSelector)) - ? '#d36086' - : '#EF843C'; - const isOpen = await this.doesLegendColorChoiceExist(arbitraryColor); - if (!isOpen) { - throw new Error('legend color selector not open'); - } - }); - } + const legendEntries = await this.find.allByCssSelector( + '.visLegend__button', + this.defaultFindTimeout * 2 + ); + return await Promise.all( + legendEntries.map(async (chart) => await chart.getAttribute('data-label')) + ); + } - public async filterOnTableCell(columnIndex: number, rowIndex: number) { - await retry.try(async () => { - const cell = await dataGrid.getCellElement(rowIndex, columnIndex); - await cell.click(); - const filterBtn = await testSubjects.findDescendant( - 'tbvChartCell__filterForCellValue', - cell + public async openLegendOptionColors(name: string, chartSelector: string) { + await this.waitForVisualizationRenderingStabilized(); + await this.retry.try(async () => { + if ( + (await this.isNewLibraryChart(xyChartSelector)) || + (await this.isNewLibraryChart(pieChartSelector)) + ) { + const chart = await this.find.byCssSelector(chartSelector); + const legendItemColor = await chart.findByCssSelector( + `[data-ech-series-name="${name}"] .echLegendItem__color` ); - await common.sleep(2000); - filterBtn.click(); - }); - } + legendItemColor.click(); + } else { + // This click has been flaky in opening the legend, hence the this.retry. See + // https://github.com/elastic/kibana/issues/17468 + await this.testSubjects.click(`legend-${name}`); + } - public async getMarkdownText() { - const markdownContainer = await testSubjects.find('markdownBody'); - return markdownContainer.getVisibleText(); - } + await this.waitForVisualizationRenderingStabilized(); + // arbitrary color chosen, any available would do + const arbitraryColor = (await this.isNewLibraryChart(xyChartSelector)) + ? '#d36086' + : '#EF843C'; + const isOpen = await this.doesLegendColorChoiceExist(arbitraryColor); + if (!isOpen) { + throw new Error('legend color selector not open'); + } + }); + } - public async getMarkdownBodyDescendentText(selector: string) { - const markdownContainer = await testSubjects.find('markdownBody'); - const element = await find.descendantDisplayedByCssSelector(selector, markdownContainer); - return element.getVisibleText(); - } + public async filterOnTableCell(columnIndex: number, rowIndex: number) { + await this.retry.try(async () => { + const cell = await this.dataGrid.getCellElement(rowIndex, columnIndex); + await cell.click(); + const filterBtn = await this.testSubjects.findDescendant( + 'tbvChartCell__filterForCellValue', + cell + ); + await this.common.sleep(2000); + filterBtn.click(); + }); + } - // Table visualization + public async getMarkdownText() { + const markdownContainer = await this.testSubjects.find('markdownBody'); + return markdownContainer.getVisibleText(); + } - public async getTableVisNoResult() { - return await testSubjects.find('tbvChartContainer>visNoResult'); - } + public async getMarkdownBodyDescendentText(selector: string) { + const markdownContainer = await this.testSubjects.find('markdownBody'); + const element = await this.find.descendantDisplayedByCssSelector(selector, markdownContainer); + return element.getVisibleText(); + } - /** - * This function returns the text displayed in the Table Vis header - */ - public async getTableVisHeader() { - return await testSubjects.getVisibleText('dataGridHeader'); - } + // Table visualization - public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { - const headers = await dataGrid.getHeaders(); - const fieldColumnIndex = headers.indexOf(fieldName); - const cell = await dataGrid.getCellElement(rowIndex, fieldColumnIndex + 1); - return await cell.findByTagName('a'); - } + public async getTableVisNoResult() { + return await this.testSubjects.find('tbvChartContainer>visNoResult'); + } - /** - * Function to retrieve data from within a table visualization. - */ - public async getTableVisContent({ stripEmptyRows = true } = {}) { - return await retry.try(async () => { - const container = await testSubjects.find('tbvChart'); - const allTables = await testSubjects.findAllDescendant('dataGridWrapper', container); - - if (allTables.length === 0) { - return []; - } - - const allData = await Promise.all( - allTables.map(async (t) => { - let data = await dataGrid.getDataFromElement(t, 'tbvChartCellContent'); - if (stripEmptyRows) { - data = data.filter( - (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) - ); - } - return data; - }) - ); + /** + * This function returns the text displayed in the Table Vis header + */ + public async getTableVisHeader() { + return await this.testSubjects.getVisibleText('dataGridHeader'); + } - if (allTables.length === 1) { - // If there was only one table we return only the data for that table - // This prevents an unnecessary array around that single table, which - // is the case we have in most tests. - return allData[0]; - } + public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { + const headers = await this.dataGrid.getHeaders(); + const fieldColumnIndex = headers.indexOf(fieldName); + const cell = await this.dataGrid.getCellElement(rowIndex, fieldColumnIndex + 1); + return await cell.findByTagName('a'); + } - return allData; - }); - } + /** + * Function to retrieve data from within a table visualization. + */ + public async getTableVisContent({ stripEmptyRows = true } = {}) { + return await this.retry.try(async () => { + const container = await this.testSubjects.find('tbvChart'); + const allTables = await this.testSubjects.findAllDescendant('dataGridWrapper', container); - public async getMetric() { - const elements = await find.allByCssSelector( - '[data-test-subj="visualizationLoader"] .mtrVis__container' - ); - const values = await Promise.all( - elements.map(async (element) => { - const text = await element.getVisibleText(); - return text; - }) - ); - return values - .filter((item) => item.length > 0) - .reduce((arr: string[], item) => arr.concat(item.split('\n')), []); - } + if (allTables.length === 0) { + return []; + } - public async getGaugeValue() { - const elements = await find.allByCssSelector( - '[data-test-subj="visualizationLoader"] .chart svg text' - ); - const values = await Promise.all( - elements.map(async (element) => { - const text = await element.getVisibleText(); - return text; + const allData = await Promise.all( + allTables.map(async (t) => { + let data = await this.dataGrid.getDataFromElement(t, 'tbvChartCellContent'); + if (stripEmptyRows) { + data = data.filter( + (row) => row.length > 0 && row.some((cell) => cell.trim().length > 0) + ); + } + return data; }) ); - return values.filter((item) => item.length > 0); - } - public async getRightValueAxesCount() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const yAxes = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; - return yAxes.filter(({ position }) => position === Position.Right).length; + if (allTables.length === 1) { + // If there was only one table we return only the data for that table + // This prevents an unnecessary array around that single table, which + // is the case we have in most tests. + return allData[0]; } - const axes = await find.allByCssSelector('.visAxis__column--right g.axis'); - return axes.length; - } - public async clickOnGaugeByLabel(label: string) { - const gauge = await testSubjects.find(`visGauge__meter--${label}`); - const gaugeSize = await gauge.getSize(); - const gaugeHeight = gaugeSize.height; - // To click at Gauge arc instead of the center of SVG element - // the offset for a click is calculated as half arc height without 1 pixel - const yOffset = 1 - Math.floor(gaugeHeight / 2); + return allData; + }); + } - await gauge.clickMouseButton({ xOffset: 0, yOffset }); - } + public async getMetric() { + const elements = await this.find.allByCssSelector( + '[data-test-subj="visualizationLoader"] .mtrVis__container' + ); + const values = await Promise.all( + elements.map(async (element) => { + const text = await element.getVisibleText(); + return text; + }) + ); + return values + .filter((item) => item.length > 0) + .reduce((arr: string[], item) => arr.concat(item.split('\n')), []); + } - public async getHistogramSeriesCount() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; - return bars.filter(({ visible }) => visible).length; - } + public async getGaugeValue() { + const elements = await this.find.allByCssSelector( + '[data-test-subj="visualizationLoader"] .chart svg text' + ); + const values = await Promise.all( + elements.map(async (element) => { + const text = await element.getVisibleText(); + return text; + }) + ); + return values.filter((item) => item.length > 0); + } - const series = await find.allByCssSelector('.series.histogram'); - return series.length; + public async getRightValueAxesCount() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const yAxes = (await this.getEsChartDebugState(xyChartSelector))?.axes?.y ?? []; + return yAxes.filter(({ position }) => position === Position.Right).length; } + const axes = await this.find.allByCssSelector('.visAxis__column--right g.axis'); + return axes.length; + } - public async getGridLines(): Promise> { - if (await this.isNewLibraryChart(xyChartSelector)) { - const { x, y } = (await this.getEsChartDebugState(xyChartSelector))?.axes ?? { - x: [], - y: [], - }; - return [...x, ...y].flatMap(({ gridlines }) => gridlines); - } + public async clickOnGaugeByLabel(label: string) { + const gauge = await this.testSubjects.find(`visGauge__meter--${label}`); + const gaugeSize = await gauge.getSize(); + const gaugeHeight = gaugeSize.height; + // To click at Gauge arc instead of the center of SVG element + // the offset for a click is calculated as half arc height without 1 pixel + const yOffset = 1 - Math.floor(gaugeHeight / 2); - const grid = await find.byCssSelector('g.grid'); - const $ = await grid.parseDomContent(); - return $('path') - .toArray() - .map((line) => { - const dAttribute = $(line).attr('d'); - const firstPoint = dAttribute.split('L')[0].replace('M', '').split(','); - return { - x: parseFloat(firstPoint[0]), - y: parseFloat(firstPoint[1]), - }; - }); + await gauge.clickMouseButton({ xOffset: 0, yOffset }); + } + + public async getHistogramSeriesCount() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const bars = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; + return bars.filter(({ visible }) => visible).length; } - public async getChartValues() { - if (await this.isNewLibraryChart(xyChartSelector)) { - const barSeries = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; - return barSeries.filter(({ visible }) => visible).flatMap((bars) => bars.labels); - } + const series = await this.find.allByCssSelector('.series.histogram'); + return series.length; + } - const elements = await find.allByCssSelector('.series.histogram text'); - const values = await Promise.all( - elements.map(async (element) => { - const text = await element.getVisibleText(); - return text; - }) - ); - return values; - } + public async getGridLines(): Promise> { + if (await this.isNewLibraryChart(xyChartSelector)) { + const { x, y } = (await this.getEsChartDebugState(xyChartSelector))?.axes ?? { + x: [], + y: [], + }; + return [...x, ...y].flatMap(({ gridlines }) => gridlines); + } + + const grid = await this.find.byCssSelector('g.grid'); + const $ = await grid.parseDomContent(); + return $('path') + .toArray() + .map((line) => { + const dAttribute = $(line).attr('d'); + const firstPoint = dAttribute.split('L')[0].replace('M', '').split(','); + return { + x: parseFloat(firstPoint[0]), + y: parseFloat(firstPoint[1]), + }; + }); } - return new VisualizeChart(); + public async getChartValues() { + if (await this.isNewLibraryChart(xyChartSelector)) { + const barSeries = (await this.getEsChartDebugState(xyChartSelector))?.bars ?? []; + return barSeries.filter(({ visible }) => visible).flatMap((bars) => bars.labels); + } + + const elements = await this.find.allByCssSelector('.series.histogram text'); + const values = await Promise.all( + elements.map(async (element) => { + const text = await element.getVisibleText(); + return text; + }) + ); + return values; + } } diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index d311f752fd4907..ab458c2c0fdc12 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -7,535 +7,529 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; - -export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const find = getService('find'); - const log = getService('log'); - const retry = getService('retry'); - const browser = getService('browser'); - const testSubjects = getService('testSubjects'); - const comboBox = getService('comboBox'); - const elasticChart = getService('elasticChart'); - const { common, header, visChart } = getPageObjects(['common', 'header', 'visChart']); - - interface IntervalOptions { - type?: 'default' | 'numeric' | 'custom'; - aggNth?: number; - append?: boolean; - } - - class VisualizeEditorPage { - public async clickDataTab() { - await testSubjects.click('visEditorTab__data'); - } +import { FtrService } from '../ftr_provider_context'; - public async clickOptionsTab() { - await testSubjects.click('visEditorTab__options'); - } +interface IntervalOptions { + type?: 'default' | 'numeric' | 'custom'; + aggNth?: number; + append?: boolean; +} - public async clickMetricsAndAxes() { - await testSubjects.click('visEditorTab__advanced'); - } +export class VisualizeEditorPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly comboBox = this.ctx.getService('comboBox'); + private readonly elasticChart = this.ctx.getService('elasticChart'); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); + private readonly visChart = this.ctx.getPageObject('visChart'); + + public async clickDataTab() { + await this.testSubjects.click('visEditorTab__data'); + } - public async clickVisEditorTab(tabName: string) { - await testSubjects.click(`visEditorTab__${tabName}`); - await header.waitUntilLoadingHasFinished(); - } + public async clickOptionsTab() { + await this.testSubjects.click('visEditorTab__options'); + } - public async addInputControl(type?: string) { - if (type) { - const selectInput = await testSubjects.find('selectControlType'); - await selectInput.type(type); - } - await testSubjects.click('inputControlEditorAddBtn'); - await header.waitUntilLoadingHasFinished(); - } + public async clickMetricsAndAxes() { + await this.testSubjects.click('visEditorTab__advanced'); + } - public async inputControlClear() { - await testSubjects.click('inputControlClearBtn'); - await header.waitUntilLoadingHasFinished(); - } + public async clickVisEditorTab(tabName: string) { + await this.testSubjects.click(`visEditorTab__${tabName}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async inputControlSubmit() { - await testSubjects.clickWhenNotDisabled('inputControlSubmitBtn'); - await visChart.waitForVisualizationRenderingStabilized(); + public async addInputControl(type?: string) { + if (type) { + const selectInput = await this.testSubjects.find('selectControlType'); + await selectInput.type(type); } + await this.testSubjects.click('inputControlEditorAddBtn'); + await this.header.waitUntilLoadingHasFinished(); + } - public async clickGo() { - if (await visChart.isNewChartsLibraryEnabled()) { - await elasticChart.setNewChartUiDebugFlag(); - } + public async inputControlClear() { + await this.testSubjects.click('inputControlClearBtn'); + await this.header.waitUntilLoadingHasFinished(); + } - const prevRenderingCount = await visChart.getVisualizationRenderingCount(); - log.debug(`Before Rendering count ${prevRenderingCount}`); - await testSubjects.clickWhenNotDisabled('visualizeEditorRenderButton'); - await visChart.waitForRenderingCount(prevRenderingCount + 1); - } + public async inputControlSubmit() { + await this.testSubjects.clickWhenNotDisabled('inputControlSubmitBtn'); + await this.visChart.waitForVisualizationRenderingStabilized(); + } - public async removeDimension(aggNth: number) { - await testSubjects.click(`visEditorAggAccordion${aggNth} > removeDimensionBtn`); + public async clickGo() { + if (await this.visChart.isNewChartsLibraryEnabled()) { + await this.elasticChart.setNewChartUiDebugFlag(); } - public async setFilterParams(aggNth: number, indexPattern: string, field: string) { - await comboBox.set(`indexPatternSelect-${aggNth}`, indexPattern); - await comboBox.set(`fieldSelect-${aggNth}`, field); - } + const prevRenderingCount = await this.visChart.getVisualizationRenderingCount(); + this.log.debug(`Before Rendering count ${prevRenderingCount}`); + await this.testSubjects.clickWhenNotDisabled('visualizeEditorRenderButton'); + await this.visChart.waitForRenderingCount(prevRenderingCount + 1); + } - public async setFilterRange(aggNth: number, min: string, max: string) { - const control = await testSubjects.find(`inputControl${aggNth}`); - const inputMin = await control.findByCssSelector('[name$="minValue"]'); - await inputMin.type(min); - const inputMax = await control.findByCssSelector('[name$="maxValue"]'); - await inputMax.type(max); - } + public async removeDimension(aggNth: number) { + await this.testSubjects.click(`visEditorAggAccordion${aggNth} > removeDimensionBtn`); + } - public async clickSplitDirection(direction: string) { - const radioBtn = await find.byCssSelector(`[data-test-subj="visEditorSplitBy-${direction}"]`); - await radioBtn.click(); - } + public async setFilterParams(aggNth: number, indexPattern: string, field: string) { + await this.comboBox.set(`indexPatternSelect-${aggNth}`, indexPattern); + await this.comboBox.set(`fieldSelect-${aggNth}`, field); + } - public async clickAddDateRange() { - await testSubjects.click(`visEditorAddDateRange`); - } + public async setFilterRange(aggNth: number, min: string, max: string) { + const control = await this.testSubjects.find(`inputControl${aggNth}`); + const inputMin = await control.findByCssSelector('[name$="minValue"]'); + await inputMin.type(min); + const inputMax = await control.findByCssSelector('[name$="maxValue"]'); + await inputMax.type(max); + } - public async setDateRangeByIndex(index: string, from: string, to: string) { - await testSubjects.setValue(`visEditorDateRange${index}__from`, from); - await testSubjects.setValue(`visEditorDateRange${index}__to`, to); - } + public async clickSplitDirection(direction: string) { + const radioBtn = await this.find.byCssSelector( + `[data-test-subj="visEditorSplitBy-${direction}"]` + ); + await radioBtn.click(); + } - /** - * Adds new bucket - * @param bucketName bucket name, like 'X-axis', 'Split rows', 'Split series' - * @param type aggregation type, like 'buckets', 'metrics' - */ - public async clickBucket(bucketName: string, type = 'buckets') { - await testSubjects.click(`visEditorAdd_${type}`); - await testSubjects.click(`visEditorAdd_${type}_${bucketName}`); - } + public async clickAddDateRange() { + await this.testSubjects.click(`visEditorAddDateRange`); + } - public async clickEnableCustomRanges() { - await testSubjects.click('heatmapUseCustomRanges'); - } + public async setDateRangeByIndex(index: string, from: string, to: string) { + await this.testSubjects.setValue(`visEditorDateRange${index}__from`, from); + await this.testSubjects.setValue(`visEditorDateRange${index}__to`, to); + } - public async clickAddRange() { - await testSubjects.click(`heatmapColorRange__addRangeButton`); - } + /** + * Adds new bucket + * @param bucketName bucket name, like 'X-axis', 'Split rows', 'Split series' + * @param type aggregation type, like 'buckets', 'metrics' + */ + public async clickBucket(bucketName: string, type = 'buckets') { + await this.testSubjects.click(`visEditorAdd_${type}`); + await this.testSubjects.click(`visEditorAdd_${type}_${bucketName}`); + } - public async setCustomRangeByIndex(index: string | number, from: string, to: string) { - await testSubjects.setValue(`heatmapColorRange${index}__from`, from); - await testSubjects.setValue(`heatmapColorRange${index}__to`, to); - } + public async clickEnableCustomRanges() { + await this.testSubjects.click('heatmapUseCustomRanges'); + } - public async changeHeatmapColorNumbers(value = 6) { - await testSubjects.setValue('heatmapColorsNumber', `${value}`); - } + public async clickAddRange() { + await this.testSubjects.click(`heatmapColorRange__addRangeButton`); + } - public async getBucketErrorMessage() { - const error = await find.byCssSelector( - '[data-test-subj="bucketsAggGroup"] [data-test-subj="defaultEditorAggSelect"] + .euiFormErrorText' - ); - const errorMessage = await error.getAttribute('innerText'); - log.debug(errorMessage); - return errorMessage; - } + public async setCustomRangeByIndex(index: string | number, from: string, to: string) { + await this.testSubjects.setValue(`heatmapColorRange${index}__from`, from); + await this.testSubjects.setValue(`heatmapColorRange${index}__to`, to); + } - public async addNewFilterAggregation() { - await testSubjects.click('visEditorAddFilterButton'); - } + public async changeHeatmapColorNumbers(value = 6) { + await this.testSubjects.setValue('heatmapColorsNumber', `${value}`); + } - public async selectField( - fieldValue: string, - groupName = 'buckets', - isChildAggregation = false - ) { - log.debug(`selectField ${fieldValue}`); - const selector = ` - [data-test-subj="${groupName}AggGroup"] - [data-test-subj^="visEditorAggAccordion"].euiAccordion-isOpen - [data-test-subj="visAggEditorParams"] - ${isChildAggregation ? '.visEditorAgg__subAgg' : ''} - [data-test-subj="visDefaultEditorField"] - `; - const fieldEl = await find.byCssSelector(selector); - await comboBox.setElement(fieldEl, fieldValue); - } + public async getBucketErrorMessage() { + const error = await this.find.byCssSelector( + '[data-test-subj="bucketsAggGroup"] [data-test-subj="defaultEditorAggSelect"] + .euiFormErrorText' + ); + const errorMessage = await error.getAttribute('innerText'); + this.log.debug(errorMessage); + return errorMessage; + } - public async selectOrderByMetric(aggNth: number, metric: string) { - const sortSelect = await testSubjects.find(`visEditorOrderBy${aggNth}`); - const sortMetric = await sortSelect.findByCssSelector(`option[value="${metric}"]`); - await sortMetric.click(); - } + public async addNewFilterAggregation() { + await this.testSubjects.click('visEditorAddFilterButton'); + } - public async selectCustomSortMetric(aggNth: number, metric: string, field: string) { - await this.selectOrderByMetric(aggNth, 'custom'); - await this.selectAggregation(metric, 'buckets', true); - await this.selectField(field, 'buckets', true); - } + public async selectField(fieldValue: string, groupName = 'buckets', isChildAggregation = false) { + this.log.debug(`selectField ${fieldValue}`); + const selector = ` + [data-test-subj="${groupName}AggGroup"] + [data-test-subj^="visEditorAggAccordion"].euiAccordion-isOpen + [data-test-subj="visAggEditorParams"] + ${isChildAggregation ? '.visEditorAgg__subAgg' : ''} + [data-test-subj="visDefaultEditorField"] + `; + const fieldEl = await this.find.byCssSelector(selector); + await this.comboBox.setElement(fieldEl, fieldValue); + } - public async selectAggregation( - aggValue: string, - groupName = 'buckets', - isChildAggregation = false - ) { - const comboBoxElement = await find.byCssSelector(` - [data-test-subj="${groupName}AggGroup"] - [data-test-subj^="visEditorAggAccordion"].euiAccordion-isOpen - ${isChildAggregation ? '.visEditorAgg__subAgg' : ''} - [data-test-subj="defaultEditorAggSelect"] - `); - - await comboBox.setElement(comboBoxElement, aggValue); - await common.sleep(500); - } + public async selectOrderByMetric(aggNth: number, metric: string) { + const sortSelect = await this.testSubjects.find(`visEditorOrderBy${aggNth}`); + const sortMetric = await sortSelect.findByCssSelector(`option[value="${metric}"]`); + await sortMetric.click(); + } - /** - * Set the test for a filter aggregation. - * @param {*} filterValue the string value of the filter - * @param {*} filterIndex used when multiple filters are configured on the same aggregation - * @param {*} aggregationId the ID if the aggregation. On Tests, it start at from 2 - */ - public async setFilterAggregationValue( - filterValue: string, - filterIndex = 0, - aggregationId = 2 - ) { - await testSubjects.setValue( - `visEditorFilterInput_${aggregationId}_${filterIndex}`, - filterValue - ); - } + public async selectCustomSortMetric(aggNth: number, metric: string, field: string) { + await this.selectOrderByMetric(aggNth, 'custom'); + await this.selectAggregation(metric, 'buckets', true); + await this.selectField(field, 'buckets', true); + } - public async setValue(newValue: string) { - const input = await find.byCssSelector('[data-test-subj="visEditorPercentileRanks"] input'); - await input.clearValue(); - await input.type(newValue); - } + public async selectAggregation( + aggValue: string, + groupName = 'buckets', + isChildAggregation = false + ) { + const comboBoxElement = await this.find.byCssSelector(` + [data-test-subj="${groupName}AggGroup"] + [data-test-subj^="visEditorAggAccordion"].euiAccordion-isOpen + ${isChildAggregation ? '.visEditorAgg__subAgg' : ''} + [data-test-subj="defaultEditorAggSelect"] + `); + + await this.comboBox.setElement(comboBoxElement, aggValue); + await this.common.sleep(500); + } - public async clickEditorSidebarCollapse() { - await testSubjects.click('collapseSideBarButton'); - } + /** + * Set the test for a filter aggregation. + * @param {*} filterValue the string value of the filter + * @param {*} filterIndex used when multiple filters are configured on the same aggregation + * @param {*} aggregationId the ID if the aggregation. On Tests, it start at from 2 + */ + public async setFilterAggregationValue(filterValue: string, filterIndex = 0, aggregationId = 2) { + await this.testSubjects.setValue( + `visEditorFilterInput_${aggregationId}_${filterIndex}`, + filterValue + ); + } - public async clickDropPartialBuckets() { - await testSubjects.click('dropPartialBucketsCheckbox'); - } + public async setValue(newValue: string) { + const input = await this.find.byCssSelector( + '[data-test-subj="visEditorPercentileRanks"] input' + ); + await input.clearValue(); + await input.type(newValue); + } - public async expectMarkdownTextArea() { - await testSubjects.existOrFail('markdownTextarea'); - } + public async clickEditorSidebarCollapse() { + await this.testSubjects.click('collapseSideBarButton'); + } - public async setMarkdownTxt(markdownTxt: string) { - const input = await testSubjects.find('markdownTextarea'); - await input.clearValue(); - await input.type(markdownTxt); - } + public async clickDropPartialBuckets() { + await this.testSubjects.click('dropPartialBucketsCheckbox'); + } - public async isSwitchChecked(selector: string) { - const checkbox = await testSubjects.find(selector); - const isChecked = await checkbox.getAttribute('aria-checked'); - return isChecked === 'true'; - } + public async expectMarkdownTextArea() { + await this.testSubjects.existOrFail('markdownTextarea'); + } - public async checkSwitch(selector: string) { - const isChecked = await this.isSwitchChecked(selector); - if (!isChecked) { - log.debug(`checking switch ${selector}`); - await testSubjects.click(selector); - } - } + public async setMarkdownTxt(markdownTxt: string) { + const input = await this.testSubjects.find('markdownTextarea'); + await input.clearValue(); + await input.type(markdownTxt); + } - public async uncheckSwitch(selector: string) { - const isChecked = await this.isSwitchChecked(selector); - if (isChecked) { - log.debug(`unchecking switch ${selector}`); - await testSubjects.click(selector); - } - } + public async isSwitchChecked(selector: string) { + const checkbox = await this.testSubjects.find(selector); + const isChecked = await checkbox.getAttribute('aria-checked'); + return isChecked === 'true'; + } - public async setIsFilteredByCollarCheckbox(value = true) { - await retry.try(async () => { - const isChecked = await this.isSwitchChecked('isFilteredByCollarCheckbox'); - if (isChecked !== value) { - await testSubjects.click('isFilteredByCollarCheckbox'); - throw new Error('isFilteredByCollar not set correctly'); - } - }); + public async checkSwitch(selector: string) { + const isChecked = await this.isSwitchChecked(selector); + if (!isChecked) { + this.log.debug(`checking switch ${selector}`); + await this.testSubjects.click(selector); } + } - public async setCustomLabel(label: string, index: number | string = 1) { - const customLabel = await testSubjects.find(`visEditorStringInput${index}customLabel`); - customLabel.type(label); + public async uncheckSwitch(selector: string) { + const isChecked = await this.isSwitchChecked(selector); + if (isChecked) { + this.log.debug(`unchecking switch ${selector}`); + await this.testSubjects.click(selector); } + } - public async selectYAxisAggregation(agg: string, field: string, label: string, index = 1) { - // index starts on the first "count" metric at 1 - // Each new metric or aggregation added to a visualization gets the next index. - // So to modify a metric or aggregation tests need to keep track of the - // order they are added. - await this.toggleOpenEditor(index); + public async setIsFilteredByCollarCheckbox(value = true) { + await this.retry.try(async () => { + const isChecked = await this.isSwitchChecked('isFilteredByCollarCheckbox'); + if (isChecked !== value) { + await this.testSubjects.click('isFilteredByCollarCheckbox'); + throw new Error('isFilteredByCollar not set correctly'); + } + }); + } - // select our agg - const aggSelect = await find.byCssSelector( - `#visEditorAggAccordion${index} [data-test-subj="defaultEditorAggSelect"]` - ); - await comboBox.setElement(aggSelect, agg); + public async setCustomLabel(label: string, index: number | string = 1) { + const customLabel = await this.testSubjects.find(`visEditorStringInput${index}customLabel`); + customLabel.type(label); + } - const fieldSelect = await find.byCssSelector( - `#visEditorAggAccordion${index} [data-test-subj="visDefaultEditorField"]` - ); - // select our field - await comboBox.setElement(fieldSelect, field); - // enter custom label - await this.setCustomLabel(label, index); - } + public async selectYAxisAggregation(agg: string, field: string, label: string, index = 1) { + // index starts on the first "count" metric at 1 + // Each new metric or aggregation added to a visualization gets the next index. + // So to modify a metric or aggregation tests need to keep track of the + // order they are added. + await this.toggleOpenEditor(index); + + // select our agg + const aggSelect = await this.find.byCssSelector( + `#visEditorAggAccordion${index} [data-test-subj="defaultEditorAggSelect"]` + ); + await this.comboBox.setElement(aggSelect, agg); + + const fieldSelect = await this.find.byCssSelector( + `#visEditorAggAccordion${index} [data-test-subj="visDefaultEditorField"]` + ); + // select our field + await this.comboBox.setElement(fieldSelect, field); + // enter custom label + await this.setCustomLabel(label, index); + } - public async getField() { - return await comboBox.getComboBoxSelectedOptions('visDefaultEditorField'); - } + public async getField() { + return await this.comboBox.getComboBoxSelectedOptions('visDefaultEditorField'); + } - public async sizeUpEditor() { - const resizerPanel = await testSubjects.find('euiResizableButton'); - // Drag panel 100 px left - await browser.dragAndDrop({ location: resizerPanel }, { location: { x: -100, y: 0 } }); - } + public async sizeUpEditor() { + const resizerPanel = await this.testSubjects.find('euiResizableButton'); + // Drag panel 100 px left + await this.browser.dragAndDrop({ location: resizerPanel }, { location: { x: -100, y: 0 } }); + } - public async toggleDisabledAgg(agg: string | number) { - await testSubjects.click(`visEditorAggAccordion${agg} > ~toggleDisableAggregationBtn`); - await header.waitUntilLoadingHasFinished(); - } + public async toggleDisabledAgg(agg: string | number) { + await this.testSubjects.click(`visEditorAggAccordion${agg} > ~toggleDisableAggregationBtn`); + await this.header.waitUntilLoadingHasFinished(); + } - public async toggleAggregationEditor(agg: string | number) { - await find.clickByCssSelector( - `[data-test-subj="visEditorAggAccordion${agg}"] .euiAccordion__button` - ); - await header.waitUntilLoadingHasFinished(); - } + public async toggleAggregationEditor(agg: string | number) { + await this.find.clickByCssSelector( + `[data-test-subj="visEditorAggAccordion${agg}"] .euiAccordion__button` + ); + await this.header.waitUntilLoadingHasFinished(); + } - public async toggleOtherBucket(agg: string | number = 2) { - await testSubjects.click(`visEditorAggAccordion${agg} > otherBucketSwitch`); - } + public async toggleOtherBucket(agg: string | number = 2) { + await this.testSubjects.click(`visEditorAggAccordion${agg} > otherBucketSwitch`); + } - public async toggleMissingBucket(agg: string | number = 2) { - await testSubjects.click(`visEditorAggAccordion${agg} > missingBucketSwitch`); - } + public async toggleMissingBucket(agg: string | number = 2) { + await this.testSubjects.click(`visEditorAggAccordion${agg} > missingBucketSwitch`); + } - public async toggleScaleMetrics() { - await testSubjects.click('scaleMetricsSwitch'); - } + public async toggleScaleMetrics() { + await this.testSubjects.click('scaleMetricsSwitch'); + } - public async toggleAutoMode() { - await testSubjects.click('visualizeEditorAutoButton'); - } + public async toggleAutoMode() { + await this.testSubjects.click('visualizeEditorAutoButton'); + } - public async togglePieLegend() { - await testSubjects.click('visTypePieAddLegendSwitch'); - } + public async togglePieLegend() { + await this.testSubjects.click('visTypePieAddLegendSwitch'); + } - public async togglePieNestedLegend() { - await testSubjects.click('visTypePieNestedLegendSwitch'); - } + public async togglePieNestedLegend() { + await this.testSubjects.click('visTypePieNestedLegendSwitch'); + } - public async isApplyEnabled() { - const applyButton = await testSubjects.find('visualizeEditorRenderButton'); - return await applyButton.isEnabled(); - } + public async isApplyEnabled() { + const applyButton = await this.testSubjects.find('visualizeEditorRenderButton'); + return await applyButton.isEnabled(); + } - public async toggleAccordion(id: string, toState = 'true') { - const toggle = await find.byCssSelector(`button[aria-controls="${id}"]`); - const toggleOpen = await toggle.getAttribute('aria-expanded'); - log.debug(`toggle ${id} expand = ${toggleOpen}`); - if (toggleOpen !== toState) { - log.debug(`toggle ${id} click()`); - await toggle.click(); - } + public async toggleAccordion(id: string, toState = 'true') { + const toggle = await this.find.byCssSelector(`button[aria-controls="${id}"]`); + const toggleOpen = await toggle.getAttribute('aria-expanded'); + this.log.debug(`toggle ${id} expand = ${toggleOpen}`); + if (toggleOpen !== toState) { + this.log.debug(`toggle ${id} click()`); + await toggle.click(); } + } - public async toggleOpenEditor(index: number, toState = 'true') { - // index, see selectYAxisAggregation - await this.toggleAccordion(`visEditorAggAccordion${index}`, toState); - } + public async toggleOpenEditor(index: number, toState = 'true') { + // index, see selectYAxisAggregation + await this.toggleAccordion(`visEditorAggAccordion${index}`, toState); + } - public async toggleAdvancedParams(aggId: string) { - const accordion = await testSubjects.find(`advancedParams-${aggId}`); - const accordionButton = await find.descendantDisplayedByCssSelector('button', accordion); - await accordionButton.click(); - } + public async toggleAdvancedParams(aggId: string) { + const accordion = await this.testSubjects.find(`advancedParams-${aggId}`); + const accordionButton = await this.find.descendantDisplayedByCssSelector('button', accordion); + await accordionButton.click(); + } - public async inputValueInCodeEditor(value: string) { - const codeEditor = await find.byCssSelector('.react-monaco-editor-container'); - const textarea = await codeEditor.findByClassName('monaco-mouse-cursor-text'); + public async inputValueInCodeEditor(value: string) { + const codeEditor = await this.find.byCssSelector('.react-monaco-editor-container'); + const textarea = await codeEditor.findByClassName('monaco-mouse-cursor-text'); - await textarea.click(); - await browser.pressKeys(value); - } + await textarea.click(); + await this.browser.pressKeys(value); + } - public async clickReset() { - await testSubjects.click('visualizeEditorResetButton'); - await visChart.waitForVisualization(); - } + public async clickReset() { + await this.testSubjects.click('visualizeEditorResetButton'); + await this.visChart.waitForVisualization(); + } - public async clickYAxisOptions(axisId: string) { - await testSubjects.click(`toggleYAxisOptions-${axisId}`); - } + public async clickYAxisOptions(axisId: string) { + await this.testSubjects.click(`toggleYAxisOptions-${axisId}`); + } - public async changeYAxisShowCheckbox(axisId: string, enabled: boolean) { - const selector = `valueAxisShow-${axisId}`; - const button = await testSubjects.find(selector); - const isEnabled = (await button.getAttribute('aria-checked')) === 'true'; - if (enabled !== isEnabled) { - await button.click(); - } + public async changeYAxisShowCheckbox(axisId: string, enabled: boolean) { + const selector = `valueAxisShow-${axisId}`; + const button = await this.testSubjects.find(selector); + const isEnabled = (await button.getAttribute('aria-checked')) === 'true'; + if (enabled !== isEnabled) { + await button.click(); } + } - public async changeYAxisFilterLabelsCheckbox(axisId: string, enabled: boolean) { - const selector = `yAxisFilterLabelsCheckbox-${axisId}`; - const button = await testSubjects.find(selector); - const isEnabled = (await button.getAttribute('aria-checked')) === 'true'; - if (enabled !== isEnabled) { - await button.click(); - } + public async changeYAxisFilterLabelsCheckbox(axisId: string, enabled: boolean) { + const selector = `yAxisFilterLabelsCheckbox-${axisId}`; + const button = await this.testSubjects.find(selector); + const isEnabled = (await button.getAttribute('aria-checked')) === 'true'; + if (enabled !== isEnabled) { + await button.click(); } + } - public async setSize(newValue: number, aggId?: number) { - const dataTestSubj = aggId - ? `visEditorAggAccordion${aggId} > sizeParamEditor` - : 'sizeParamEditor'; - await testSubjects.setValue(dataTestSubj, String(newValue)); - } + public async setSize(newValue: number, aggId?: number) { + const dataTestSubj = aggId + ? `visEditorAggAccordion${aggId} > sizeParamEditor` + : 'sizeParamEditor'; + await this.testSubjects.setValue(dataTestSubj, String(newValue)); + } - public async selectChartMode(mode: string) { - const selector = await find.byCssSelector(`#seriesMode0 > option[value="${mode}"]`); - await selector.click(); - } + public async selectChartMode(mode: string) { + const selector = await this.find.byCssSelector(`#seriesMode0 > option[value="${mode}"]`); + await selector.click(); + } - public async selectYAxisScaleType(axisId: string, scaleType: string) { - const selector = await find.byCssSelector( - `#scaleSelectYAxis-${axisId} > option[value="${scaleType}"]` - ); - await selector.click(); - } + public async selectYAxisScaleType(axisId: string, scaleType: string) { + const selector = await this.find.byCssSelector( + `#scaleSelectYAxis-${axisId} > option[value="${scaleType}"]` + ); + await selector.click(); + } - public async selectXAxisPosition(position: string) { - const option = await (await testSubjects.find('categoryAxisPosition')).findByCssSelector( - `option[value="${position}"]` - ); - await option.click(); - } + public async selectXAxisPosition(position: string) { + const option = await (await this.testSubjects.find('categoryAxisPosition')).findByCssSelector( + `option[value="${position}"]` + ); + await option.click(); + } - public async selectYAxisMode(mode: string) { - const selector = await find.byCssSelector(`#valueAxisMode0 > option[value="${mode}"]`); - await selector.click(); - } + public async selectYAxisMode(mode: string) { + const selector = await this.find.byCssSelector(`#valueAxisMode0 > option[value="${mode}"]`); + await selector.click(); + } - public async setAxisExtents(min: string, max: string, axisId = 'ValueAxis-1') { - await this.toggleAccordion(`yAxisAccordion${axisId}`); - await this.toggleAccordion(`yAxisOptionsAccordion${axisId}`); + public async setAxisExtents(min: string, max: string, axisId = 'ValueAxis-1') { + await this.toggleAccordion(`yAxisAccordion${axisId}`); + await this.toggleAccordion(`yAxisOptionsAccordion${axisId}`); - await testSubjects.click('yAxisSetYExtents'); - await testSubjects.setValue('yAxisYExtentsMax', max); - await testSubjects.setValue('yAxisYExtentsMin', min); - } + await this.testSubjects.click('yAxisSetYExtents'); + await this.testSubjects.setValue('yAxisYExtentsMax', max); + await this.testSubjects.setValue('yAxisYExtentsMin', min); + } - public async selectAggregateWith(fieldValue: string) { - await testSubjects.selectValue('visDefaultEditorAggregateWith', fieldValue); - } + public async selectAggregateWith(fieldValue: string) { + await this.testSubjects.selectValue('visDefaultEditorAggregateWith', fieldValue); + } - public async setInterval(newValue: string | number, options: IntervalOptions = {}) { - const newValueString = `${newValue}`; - const { type = 'default', aggNth = 2, append = false } = options; - log.debug(`visEditor.setInterval(${newValueString}, {${type}, ${aggNth}, ${append}})`); - if (type === 'default') { - await comboBox.set('visEditorInterval', newValueString); - } else if (type === 'custom') { - await comboBox.setCustom('visEditorInterval', newValueString); - } else { - if (type === 'numeric') { - const autoMode = await testSubjects.getAttribute( - `visEditorIntervalSwitch${aggNth}`, - 'aria-checked' - ); - if (autoMode === 'true') { - await testSubjects.click(`visEditorIntervalSwitch${aggNth}`); - } - } - if (append) { - await testSubjects.append(`visEditorInterval${aggNth}`, String(newValueString)); - } else { - await testSubjects.setValue(`visEditorInterval${aggNth}`, String(newValueString)); + public async setInterval(newValue: string | number, options: IntervalOptions = {}) { + const newValueString = `${newValue}`; + const { type = 'default', aggNth = 2, append = false } = options; + this.log.debug(`visEditor.setInterval(${newValueString}, {${type}, ${aggNth}, ${append}})`); + if (type === 'default') { + await this.comboBox.set('visEditorInterval', newValueString); + } else if (type === 'custom') { + await this.comboBox.setCustom('visEditorInterval', newValueString); + } else { + if (type === 'numeric') { + const autoMode = await this.testSubjects.getAttribute( + `visEditorIntervalSwitch${aggNth}`, + 'aria-checked' + ); + if (autoMode === 'true') { + await this.testSubjects.click(`visEditorIntervalSwitch${aggNth}`); } } + if (append) { + await this.testSubjects.append(`visEditorInterval${aggNth}`, String(newValueString)); + } else { + await this.testSubjects.setValue(`visEditorInterval${aggNth}`, String(newValueString)); + } } + } - public async getInterval() { - return await comboBox.getComboBoxSelectedOptions('visEditorInterval'); - } + public async getInterval() { + return await this.comboBox.getComboBoxSelectedOptions('visEditorInterval'); + } - public async getNumericInterval(aggNth = 2) { - return await testSubjects.getAttribute(`visEditorInterval${aggNth}`, 'value'); - } + public async getNumericInterval(aggNth = 2) { + return await this.testSubjects.getAttribute(`visEditorInterval${aggNth}`, 'value'); + } - public async clickMetricEditor() { - await find.clickByCssSelector('[data-test-subj="metricsAggGroup"] .euiAccordion__button'); - } + public async clickMetricEditor() { + await this.find.clickByCssSelector('[data-test-subj="metricsAggGroup"] .euiAccordion__button'); + } - public async clickMetricByIndex(index: number) { - const metrics = await find.allByCssSelector( - '[data-test-subj="visualizationLoader"] .mtrVis .mtrVis__container' - ); - expect(metrics.length).greaterThan(index); - await metrics[index].click(); - } + public async clickMetricByIndex(index: number) { + const metrics = await this.find.allByCssSelector( + '[data-test-subj="visualizationLoader"] .mtrVis .mtrVis__container' + ); + expect(metrics.length).greaterThan(index); + await metrics[index].click(); + } - public async setSelectByOptionText(selectId: string, optionText: string) { - const selectField = await find.byCssSelector(`#${selectId}`); - const options = await find.allByCssSelector(`#${selectId} > option`); - const $ = await selectField.parseDomContent(); - const optionsText = $('option') - .toArray() - .map((option) => $(option).text()); - const optionIndex = optionsText.indexOf(optionText); - - if (optionIndex === -1) { - throw new Error( - `Unable to find option '${optionText}' in select ${selectId}. Available options: ${optionsText.join( - ',' - )}` - ); - } - await options[optionIndex].click(); + public async setSelectByOptionText(selectId: string, optionText: string) { + const selectField = await this.find.byCssSelector(`#${selectId}`); + const options = await this.find.allByCssSelector(`#${selectId} > option`); + const $ = await selectField.parseDomContent(); + const optionsText = $('option') + .toArray() + .map((option) => $(option).text()); + const optionIndex = optionsText.indexOf(optionText); + + if (optionIndex === -1) { + throw new Error( + `Unable to find option '${optionText}' in select ${selectId}. Available options: ${optionsText.join( + ',' + )}` + ); } + await options[optionIndex].click(); + } - // point series - - async clickAddAxis() { - return await testSubjects.click('visualizeAddYAxisButton'); - } + // point series - async setAxisTitle(title: string, aggNth = 0) { - return await testSubjects.setValue(`valueAxisTitle${aggNth}`, title); - } + async clickAddAxis() { + return await this.testSubjects.click('visualizeAddYAxisButton'); + } - public async toggleGridCategoryLines() { - return await testSubjects.click('showCategoryLines'); - } + async setAxisTitle(title: string, aggNth = 0) { + return await this.testSubjects.setValue(`valueAxisTitle${aggNth}`, title); + } - public async toggleValuesOnChart() { - return await testSubjects.click('showValuesOnChart'); - } + public async toggleGridCategoryLines() { + return await this.testSubjects.click('showCategoryLines'); + } - public async setGridValueAxis(axis: string) { - log.debug(`setGridValueAxis(${axis})`); - await find.selectValue('select#gridAxis', axis); - } + public async toggleValuesOnChart() { + return await this.testSubjects.click('showValuesOnChart'); + } - public async setSeriesAxis(seriesNth: number, axis: string) { - await find.selectValue(`select#seriesValueAxis${seriesNth}`, axis); - } + public async setGridValueAxis(axis: string) { + this.log.debug(`setGridValueAxis(${axis})`); + await this.find.selectValue('select#gridAxis', axis); + } - public async setSeriesType(seriesNth: number, type: string) { - await find.selectValue(`select#seriesType${seriesNth}`, type); - } + public async setSeriesAxis(seriesNth: number, axis: string) { + await this.find.selectValue(`select#seriesValueAxis${seriesNth}`, axis); } - return new VisualizeEditorPage(); + public async setSeriesType(seriesNth: number, type: string) { + await this.find.selectValue(`select#seriesType${seriesNth}`, type); + } } diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 78a963867b8c23..efd48346524299 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; import { VisualizeConstants } from '../../../src/plugins/visualize/public/application/visualize_constants'; import { UI_SETTINGS } from '../../../src/plugins/data/common'; @@ -23,455 +23,451 @@ type DashboardPickerOption = | 'existing-dashboard-option' | 'new-dashboard-option'; -export function VisualizePageProvider({ getService, getPageObjects }: FtrProviderContext) { - const kibanaServer = getService('kibanaServer'); - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - const find = getService('find'); - const log = getService('log'); - const globalNav = getService('globalNav'); - const listingTable = getService('listingTable'); - const queryBar = getService('queryBar'); - const elasticChart = getService('elasticChart'); - const { common, header, visEditor, visChart } = getPageObjects([ - 'common', - 'header', - 'visEditor', - 'visChart', - ]); - - /** - * This page object contains the visualization type selection, the landing page, - * and the open/save dialog functions - */ - class VisualizePage { - index = { - LOGSTASH_TIME_BASED: 'logstash-*', - LOGSTASH_NON_TIME_BASED: 'logstash*', - }; - - public async initTests() { - await kibanaServer.savedObjects.clean({ types: ['visualization'] }); - await kibanaServer.importExport.load('visualize'); - - await kibanaServer.uiSettings.replace({ - defaultIndex: 'logstash-*', - [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', - }); - } - - public async gotoVisualizationLandingPage() { - await common.navigateToApp('visualize'); - } +/** + * This page object contains the visualization type selection, the landing page, + * and the open/save dialog functions + */ +export class VisualizePageObject extends FtrService { + private readonly kibanaServer = this.ctx.getService('kibanaServer'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); + private readonly find = this.ctx.getService('find'); + private readonly log = this.ctx.getService('log'); + private readonly globalNav = this.ctx.getService('globalNav'); + private readonly listingTable = this.ctx.getService('listingTable'); + private readonly queryBar = this.ctx.getService('queryBar'); + private readonly elasticChart = this.ctx.getService('elasticChart'); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); + private readonly visEditor = this.ctx.getPageObject('visEditor'); + private readonly visChart = this.ctx.getPageObject('visChart'); + + index = { + LOGSTASH_TIME_BASED: 'logstash-*', + LOGSTASH_NON_TIME_BASED: 'logstash*', + }; + + public async initTests() { + await this.kibanaServer.savedObjects.clean({ types: ['visualization'] }); + await this.kibanaServer.importExport.load('visualize'); + + await this.kibanaServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', + }); + } - public async clickNewVisualization() { - await listingTable.clickNewButton('createVisualizationPromptButton'); - } + public async gotoVisualizationLandingPage() { + await this.common.navigateToApp('visualize'); + } - public async clickAggBasedVisualizations() { - await testSubjects.click('visGroupAggBasedExploreLink'); - } + public async clickNewVisualization() { + await this.listingTable.clickNewButton('createVisualizationPromptButton'); + } - public async goBackToGroups() { - await testSubjects.click('goBackLink'); - } + public async clickAggBasedVisualizations() { + await this.testSubjects.click('visGroupAggBasedExploreLink'); + } - public async createVisualizationPromptButton() { - await testSubjects.click('createVisualizationPromptButton'); - } + public async goBackToGroups() { + await this.testSubjects.click('goBackLink'); + } - public async getChartTypes() { - const chartTypeField = await testSubjects.find('visNewDialogTypes'); - const $ = await chartTypeField.parseDomContent(); - return $('button') - .toArray() - .map((chart) => $(chart).findTestSubject('visTypeTitle').text().trim()); - } + public async createVisualizationPromptButton() { + await this.testSubjects.click('createVisualizationPromptButton'); + } - public async getPromotedVisTypes() { - const chartTypeField = await testSubjects.find('visNewDialogGroups'); - const $ = await chartTypeField.parseDomContent(); - const promotedVisTypes: string[] = []; - $('button') - .toArray() - .forEach((chart) => { - const title = $(chart).findTestSubject('visTypeTitle').text().trim(); - if (title) { - promotedVisTypes.push(title); - } - }); - return promotedVisTypes; - } + public async getChartTypes() { + const chartTypeField = await this.testSubjects.find('visNewDialogTypes'); + const $ = await chartTypeField.parseDomContent(); + return $('button') + .toArray() + .map((chart) => $(chart).findTestSubject('visTypeTitle').text().trim()); + } - public async waitForVisualizationSelectPage() { - await retry.try(async () => { - const visualizeSelectTypePage = await testSubjects.find('visNewDialogTypes'); - if (!(await visualizeSelectTypePage.isDisplayed())) { - throw new Error('wait for visualization select page'); + public async getPromotedVisTypes() { + const chartTypeField = await this.testSubjects.find('visNewDialogGroups'); + const $ = await chartTypeField.parseDomContent(); + const promotedVisTypes: string[] = []; + $('button') + .toArray() + .forEach((chart) => { + const title = $(chart).findTestSubject('visTypeTitle').text().trim(); + if (title) { + promotedVisTypes.push(title); } }); - } + return promotedVisTypes; + } - public async clickRefresh() { - if (await visChart.isNewChartsLibraryEnabled()) { - await elasticChart.setNewChartUiDebugFlag(); + public async waitForVisualizationSelectPage() { + await this.retry.try(async () => { + const visualizeSelectTypePage = await this.testSubjects.find('visNewDialogTypes'); + if (!(await visualizeSelectTypePage.isDisplayed())) { + throw new Error('wait for visualization select page'); } - await queryBar.clickQuerySubmitButton(); - } - - public async waitForGroupsSelectPage() { - await retry.try(async () => { - const visualizeSelectGroupStep = await testSubjects.find('visNewDialogGroups'); - if (!(await visualizeSelectGroupStep.isDisplayed())) { - throw new Error('wait for vis groups select step'); - } - }); - } - - public async navigateToNewVisualization() { - await this.gotoVisualizationLandingPage(); - await header.waitUntilLoadingHasFinished(); - await this.clickNewVisualization(); - await this.waitForGroupsSelectPage(); - } + }); + } - public async navigateToNewAggBasedVisualization() { - await this.gotoVisualizationLandingPage(); - await header.waitUntilLoadingHasFinished(); - await this.clickNewVisualization(); - await this.clickAggBasedVisualizations(); - await this.waitForVisualizationSelectPage(); + public async clickRefresh() { + if (await this.visChart.isNewChartsLibraryEnabled()) { + await this.elasticChart.setNewChartUiDebugFlag(); } + await this.queryBar.clickQuerySubmitButton(); + } - public async hasVisType(type: string) { - return await testSubjects.exists(`visType-${type}`); - } + public async waitForGroupsSelectPage() { + await this.retry.try(async () => { + const visualizeSelectGroupStep = await this.testSubjects.find('visNewDialogGroups'); + if (!(await visualizeSelectGroupStep.isDisplayed())) { + throw new Error('wait for vis groups select step'); + } + }); + } - public async clickVisType(type: string) { - await testSubjects.click(`visType-${type}`); - await header.waitUntilLoadingHasFinished(); - } + public async navigateToNewVisualization() { + await this.gotoVisualizationLandingPage(); + await this.header.waitUntilLoadingHasFinished(); + await this.clickNewVisualization(); + await this.waitForGroupsSelectPage(); + } - public async clickAreaChart() { - await this.clickVisType('area'); - } + public async navigateToNewAggBasedVisualization() { + await this.gotoVisualizationLandingPage(); + await this.header.waitUntilLoadingHasFinished(); + await this.clickNewVisualization(); + await this.clickAggBasedVisualizations(); + await this.waitForVisualizationSelectPage(); + } - public async clickDataTable() { - await this.clickVisType('table'); - } + public async hasVisType(type: string) { + return await this.testSubjects.exists(`visType-${type}`); + } - public async clickLineChart() { - await this.clickVisType('line'); - } + public async clickVisType(type: string) { + await this.testSubjects.click(`visType-${type}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async clickRegionMap() { - await this.clickVisType('region_map'); - } + public async clickAreaChart() { + await this.clickVisType('area'); + } - public async hasRegionMap() { - return await this.hasVisType('region_map'); - } + public async clickDataTable() { + await this.clickVisType('table'); + } - public async clickMarkdownWidget() { - await this.clickVisType('markdown'); - } + public async clickLineChart() { + await this.clickVisType('line'); + } - public async clickMetric() { - await this.clickVisType('metric'); - } + public async clickRegionMap() { + await this.clickVisType('region_map'); + } - public async clickGauge() { - await this.clickVisType('gauge'); - } + public async hasRegionMap() { + return await this.hasVisType('region_map'); + } - public async clickPieChart() { - await this.clickVisType('pie'); - } + public async clickMarkdownWidget() { + await this.clickVisType('markdown'); + } - public async clickTileMap() { - await this.clickVisType('tile_map'); - } + public async clickMetric() { + await this.clickVisType('metric'); + } - public async hasTileMap() { - return await this.hasVisType('tile_map'); - } + public async clickGauge() { + await this.clickVisType('gauge'); + } - public async clickTagCloud() { - await this.clickVisType('tagcloud'); - } + public async clickPieChart() { + await this.clickVisType('pie'); + } - public async clickVega() { - await this.clickVisType('vega'); - } + public async clickTileMap() { + await this.clickVisType('tile_map'); + } - public async clickVisualBuilder() { - await this.clickVisType('metrics'); - } + public async hasTileMap() { + return await this.hasVisType('tile_map'); + } - public async clickVerticalBarChart() { - await this.clickVisType('histogram'); - } + public async clickTagCloud() { + await this.clickVisType('tagcloud'); + } - public async clickHeatmapChart() { - await this.clickVisType('heatmap'); - } + public async clickVega() { + await this.clickVisType('vega'); + } - public async clickInputControlVis() { - await this.clickVisType('input_control_vis'); - } + public async clickVisualBuilder() { + await this.clickVisType('metrics'); + } - public async clickLensWidget() { - await this.clickVisType('lens'); - } + public async clickVerticalBarChart() { + await this.clickVisType('histogram'); + } - public async clickMapsApp() { - await this.clickVisType('maps'); - } + public async clickHeatmapChart() { + await this.clickVisType('heatmap'); + } - public async hasMapsApp() { - return await this.hasVisType('maps'); - } + public async clickInputControlVis() { + await this.clickVisType('input_control_vis'); + } - public async createSimpleMarkdownViz(vizName: string) { - await this.gotoVisualizationLandingPage(); - await this.navigateToNewVisualization(); - await this.clickMarkdownWidget(); - await visEditor.setMarkdownTxt(vizName); - await visEditor.clickGo(); - await this.saveVisualization(vizName); - } + public async clickLensWidget() { + await this.clickVisType('lens'); + } - public async clickNewSearch(indexPattern = this.index.LOGSTASH_TIME_BASED) { - await testSubjects.click(`savedObjectTitle${indexPattern.split(' ').join('-')}`); - await header.waitUntilLoadingHasFinished(); - } + public async clickMapsApp() { + await this.clickVisType('maps'); + } - public async selectVisSourceIfRequired() { - log.debug('selectVisSourceIfRequired'); - const selectPage = await testSubjects.findAll('visualizeSelectSearch'); - if (selectPage.length) { - log.debug('a search is required for this visualization'); - await this.clickNewSearch(); - } - } + public async hasMapsApp() { + return await this.hasVisType('maps'); + } - /** - * Deletes all existing visualizations - */ - public async deleteAllVisualizations() { - await retry.try(async () => { - await listingTable.checkListingSelectAllCheckbox(); - await listingTable.clickDeleteSelected(); - await common.clickConfirmOnModal(); - await testSubjects.find('createVisualizationPromptButton'); - }); - } + public async createSimpleMarkdownViz(vizName: string) { + await this.gotoVisualizationLandingPage(); + await this.navigateToNewVisualization(); + await this.clickMarkdownWidget(); + await this.visEditor.setMarkdownTxt(vizName); + await this.visEditor.clickGo(); + await this.saveVisualization(vizName); + } - public async isBetaInfoShown() { - return await testSubjects.exists('betaVisInfo'); - } + public async clickNewSearch(indexPattern = this.index.LOGSTASH_TIME_BASED) { + await this.testSubjects.click(`savedObjectTitle${indexPattern.split(' ').join('-')}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async getBetaTypeLinks() { - return await find.allByCssSelector('[data-vis-stage="beta"]'); + public async selectVisSourceIfRequired() { + this.log.debug('selectVisSourceIfRequired'); + const selectPage = await this.testSubjects.findAll('visualizeSelectSearch'); + if (selectPage.length) { + this.log.debug('a search is required for this visualization'); + await this.clickNewSearch(); } + } - public async getExperimentalTypeLinks() { - return await find.allByCssSelector('[data-vis-stage="experimental"]'); - } + /** + * Deletes all existing visualizations + */ + public async deleteAllVisualizations() { + await this.retry.try(async () => { + await this.listingTable.checkListingSelectAllCheckbox(); + await this.listingTable.clickDeleteSelected(); + await this.common.clickConfirmOnModal(); + await this.testSubjects.find('createVisualizationPromptButton'); + }); + } - public async isExperimentalInfoShown() { - return await testSubjects.exists('experimentalVisInfo'); - } + public async isBetaInfoShown() { + return await this.testSubjects.exists('betaVisInfo'); + } - public async getExperimentalInfo() { - return await testSubjects.find('experimentalVisInfo'); - } + public async getBetaTypeLinks() { + return await this.find.allByCssSelector('[data-vis-stage="beta"]'); + } - public async getSideEditorExists() { - return await find.existsByCssSelector('.visEditor__collapsibleSidebar'); - } + public async getExperimentalTypeLinks() { + return await this.find.allByCssSelector('[data-vis-stage="experimental"]'); + } - public async clickSavedSearch(savedSearchName: string) { - await testSubjects.click(`savedObjectTitle${savedSearchName.split(' ').join('-')}`); - await header.waitUntilLoadingHasFinished(); - } + public async isExperimentalInfoShown() { + return await this.testSubjects.exists('experimentalVisInfo'); + } - public async clickUnlinkSavedSearch() { - await testSubjects.click('showUnlinkSavedSearchPopover'); - await testSubjects.click('unlinkSavedSearch'); - await header.waitUntilLoadingHasFinished(); - } + public async getExperimentalInfo() { + return await this.testSubjects.find('experimentalVisInfo'); + } - public async ensureSavePanelOpen() { - log.debug('ensureSavePanelOpen'); - await header.waitUntilLoadingHasFinished(); - const isOpen = await testSubjects.exists('savedObjectSaveModal', { timeout: 5000 }); - if (!isOpen) { - await testSubjects.click('visualizeSaveButton'); - } - } + public async getSideEditorExists() { + return await this.find.existsByCssSelector('.visEditor__collapsibleSidebar'); + } - public async clickLoadSavedVisButton() { - // TODO: Use a test subject selector once we rewrite breadcrumbs to accept each breadcrumb - // element as a child instead of building the breadcrumbs dynamically. - await find.clickByCssSelector('[href="#/"]'); - } + public async clickSavedSearch(savedSearchName: string) { + await this.testSubjects.click(`savedObjectTitle${savedSearchName.split(' ').join('-')}`); + await this.header.waitUntilLoadingHasFinished(); + } - public async loadSavedVisualization(vizName: string, { navigateToVisualize = true } = {}) { - if (navigateToVisualize) { - await this.clickLoadSavedVisButton(); - } - await this.openSavedVisualization(vizName); - } + public async clickUnlinkSavedSearch() { + await this.testSubjects.click('showUnlinkSavedSearchPopover'); + await this.testSubjects.click('unlinkSavedSearch'); + await this.header.waitUntilLoadingHasFinished(); + } - public async openSavedVisualization(vizName: string) { - const dataTestSubj = `visListingTitleLink-${vizName.split(' ').join('-')}`; - await testSubjects.click(dataTestSubj, 20000); - await header.waitUntilLoadingHasFinished(); + public async ensureSavePanelOpen() { + this.log.debug('ensureSavePanelOpen'); + await this.header.waitUntilLoadingHasFinished(); + const isOpen = await this.testSubjects.exists('savedObjectSaveModal', { timeout: 5000 }); + if (!isOpen) { + await this.testSubjects.click('visualizeSaveButton'); } + } - public async waitForVisualizationSavedToastGone() { - await testSubjects.waitForDeleted('saveVisualizationSuccess'); - } + public async clickLoadSavedVisButton() { + // TODO: Use a test subject selector once we rewrite breadcrumbs to accept each breadcrumb + // element as a child instead of building the breadcrumbs dynamically. + await this.find.clickByCssSelector('[href="#/"]'); + } - public async clickLandingPageBreadcrumbLink() { - log.debug('clickLandingPageBreadcrumbLink'); - await find.clickByCssSelector(`a[href="#${VisualizeConstants.LANDING_PAGE_PATH}"]`); + public async loadSavedVisualization(vizName: string, { navigateToVisualize = true } = {}) { + if (navigateToVisualize) { + await this.clickLoadSavedVisButton(); } + await this.openSavedVisualization(vizName); + } - /** - * Returns true if already on the landing page (that page doesn't have a link to itself). - * @returns {Promise} - */ - public async onLandingPage() { - log.debug(`VisualizePage.onLandingPage`); - return await testSubjects.exists('visualizationLandingPage'); - } + public async openSavedVisualization(vizName: string) { + const dataTestSubj = `visListingTitleLink-${vizName.split(' ').join('-')}`; + await this.testSubjects.click(dataTestSubj, 20000); + await this.header.waitUntilLoadingHasFinished(); + } - public async gotoLandingPage() { - log.debug('VisualizePage.gotoLandingPage'); - const onPage = await this.onLandingPage(); - if (!onPage) { - await retry.try(async () => { - await this.clickLandingPageBreadcrumbLink(); - const onLandingPage = await this.onLandingPage(); - if (!onLandingPage) throw new Error('Not on the landing page.'); - }); - } - } + public async waitForVisualizationSavedToastGone() { + await this.testSubjects.waitForDeleted('saveVisualizationSuccess'); + } - public async saveVisualization(vizName: string, saveModalArgs: VisualizeSaveModalArgs = {}) { - await this.ensureSavePanelOpen(); + public async clickLandingPageBreadcrumbLink() { + this.log.debug('clickLandingPageBreadcrumbLink'); + await this.find.clickByCssSelector(`a[href="#${VisualizeConstants.LANDING_PAGE_PATH}"]`); + } - await this.setSaveModalValues(vizName, saveModalArgs); - log.debug('Click Save Visualization button'); + /** + * Returns true if already on the landing page (that page doesn't have a link to itself). + * @returns {Promise} + */ + public async onLandingPage() { + this.log.debug(`VisualizePage.onLandingPage`); + return await this.testSubjects.exists('visualizationLandingPage'); + } - await testSubjects.click('confirmSaveSavedObjectButton'); + public async gotoLandingPage() { + this.log.debug('VisualizePage.gotoLandingPage'); + const onPage = await this.onLandingPage(); + if (!onPage) { + await this.retry.try(async () => { + await this.clickLandingPageBreadcrumbLink(); + const onLandingPage = await this.onLandingPage(); + if (!onLandingPage) throw new Error('Not on the landing page.'); + }); + } + } - // Confirm that the Visualization has actually been saved - await testSubjects.existOrFail('saveVisualizationSuccess'); - const message = await common.closeToast(); - await header.waitUntilLoadingHasFinished(); - await common.waitForSaveModalToClose(); + public async saveVisualization(vizName: string, saveModalArgs: VisualizeSaveModalArgs = {}) { + await this.ensureSavePanelOpen(); - return message; - } + await this.setSaveModalValues(vizName, saveModalArgs); + this.log.debug('Click Save Visualization button'); - public async setSaveModalValues( - vizName: string, - { saveAsNew, redirectToOrigin, addToDashboard, dashboardId }: VisualizeSaveModalArgs = {} - ) { - await testSubjects.setValue('savedObjectTitle', vizName); - - const saveAsNewCheckboxExists = await testSubjects.exists('saveAsNewCheckbox'); - if (saveAsNewCheckboxExists) { - const state = saveAsNew ? 'check' : 'uncheck'; - log.debug('save as new checkbox exists. Setting its state to', state); - await testSubjects.setEuiSwitch('saveAsNewCheckbox', state); - } + await this.testSubjects.click('confirmSaveSavedObjectButton'); - const redirectToOriginCheckboxExists = await testSubjects.exists('returnToOriginModeSwitch'); - if (redirectToOriginCheckboxExists) { - const state = redirectToOrigin ? 'check' : 'uncheck'; - log.debug('redirect to origin checkbox exists. Setting its state to', state); - await testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); - } + // Confirm that the Visualization has actually been saved + await this.testSubjects.existOrFail('saveVisualizationSuccess'); + const message = await this.common.closeToast(); + await this.header.waitUntilLoadingHasFinished(); + await this.common.waitForSaveModalToClose(); - const dashboardSelectorExists = await testSubjects.exists('add-to-dashboard-options'); - if (dashboardSelectorExists) { - let option: DashboardPickerOption = 'add-to-library-option'; - if (addToDashboard) { - option = dashboardId ? 'existing-dashboard-option' : 'new-dashboard-option'; - } - log.debug('save modal dashboard selector, choosing option:', option); - const dashboardSelector = await testSubjects.find('add-to-dashboard-options'); - const label = await dashboardSelector.findByCssSelector(`label[for="${option}"]`); - await label.click(); + return message; + } - if (dashboardId) { - // TODO - selecting an existing dashboard - } + public async setSaveModalValues( + vizName: string, + { saveAsNew, redirectToOrigin, addToDashboard, dashboardId }: VisualizeSaveModalArgs = {} + ) { + await this.testSubjects.setValue('savedObjectTitle', vizName); + + const saveAsNewCheckboxExists = await this.testSubjects.exists('saveAsNewCheckbox'); + if (saveAsNewCheckboxExists) { + const state = saveAsNew ? 'check' : 'uncheck'; + this.log.debug('save as new checkbox exists. Setting its state to', state); + await this.testSubjects.setEuiSwitch('saveAsNewCheckbox', state); + } + + const redirectToOriginCheckboxExists = await this.testSubjects.exists( + 'returnToOriginModeSwitch' + ); + if (redirectToOriginCheckboxExists) { + const state = redirectToOrigin ? 'check' : 'uncheck'; + this.log.debug('redirect to origin checkbox exists. Setting its state to', state); + await this.testSubjects.setEuiSwitch('returnToOriginModeSwitch', state); + } + + const dashboardSelectorExists = await this.testSubjects.exists('add-to-dashboard-options'); + if (dashboardSelectorExists) { + let option: DashboardPickerOption = 'add-to-library-option'; + if (addToDashboard) { + option = dashboardId ? 'existing-dashboard-option' : 'new-dashboard-option'; } - } + this.log.debug('save modal dashboard selector, choosing option:', option); + const dashboardSelector = await this.testSubjects.find('add-to-dashboard-options'); + const label = await dashboardSelector.findByCssSelector(`label[for="${option}"]`); + await label.click(); - public async saveVisualizationExpectSuccess( - vizName: string, - { saveAsNew, redirectToOrigin, addToDashboard, dashboardId }: VisualizeSaveModalArgs = {} - ) { - const saveMessage = await this.saveVisualization(vizName, { - saveAsNew, - redirectToOrigin, - addToDashboard, - dashboardId, - }); - if (!saveMessage) { - throw new Error( - `Expected saveVisualization to respond with the saveMessage from the toast, got ${saveMessage}` - ); + if (dashboardId) { + // TODO - selecting an existing dashboard } } + } - public async saveVisualizationExpectSuccessAndBreadcrumb( - vizName: string, - { saveAsNew = false, redirectToOrigin = false } = {} - ) { - await this.saveVisualizationExpectSuccess(vizName, { saveAsNew, redirectToOrigin }); - await retry.waitFor( - 'last breadcrumb to have new vis name', - async () => (await globalNav.getLastBreadcrumb()) === vizName + public async saveVisualizationExpectSuccess( + vizName: string, + { saveAsNew, redirectToOrigin, addToDashboard, dashboardId }: VisualizeSaveModalArgs = {} + ) { + const saveMessage = await this.saveVisualization(vizName, { + saveAsNew, + redirectToOrigin, + addToDashboard, + dashboardId, + }); + if (!saveMessage) { + throw new Error( + `Expected saveVisualization to respond with the saveMessage from the toast, got ${saveMessage}` ); } + } - public async saveVisualizationAndReturn() { - await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('visualizesaveAndReturnButton'); - await testSubjects.click('visualizesaveAndReturnButton'); - } + public async saveVisualizationExpectSuccessAndBreadcrumb( + vizName: string, + { saveAsNew = false, redirectToOrigin = false } = {} + ) { + await this.saveVisualizationExpectSuccess(vizName, { saveAsNew, redirectToOrigin }); + await this.retry.waitFor( + 'last breadcrumb to have new vis name', + async () => (await this.globalNav.getLastBreadcrumb()) === vizName + ); + } - public async linkedToOriginatingApp() { - await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('visualizesaveAndReturnButton'); - } + public async saveVisualizationAndReturn() { + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.existOrFail('visualizesaveAndReturnButton'); + await this.testSubjects.click('visualizesaveAndReturnButton'); + } - public async notLinkedToOriginatingApp() { - await header.waitUntilLoadingHasFinished(); - await testSubjects.missingOrFail('visualizesaveAndReturnButton'); - } + public async linkedToOriginatingApp() { + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.existOrFail('visualizesaveAndReturnButton'); + } - public async cancelAndReturn(showConfirmModal: boolean) { - await header.waitUntilLoadingHasFinished(); - await testSubjects.existOrFail('visualizeCancelAndReturnButton'); - await testSubjects.click('visualizeCancelAndReturnButton'); - if (showConfirmModal) { - await retry.waitFor( - 'confirm modal to show', - async () => await testSubjects.exists('appLeaveConfirmModal') - ); - await testSubjects.exists('confirmModalConfirmButton'); - await testSubjects.click('confirmModalConfirmButton'); - } - } + public async notLinkedToOriginatingApp() { + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.missingOrFail('visualizesaveAndReturnButton'); } - return new VisualizePage(); + public async cancelAndReturn(showConfirmModal: boolean) { + await this.header.waitUntilLoadingHasFinished(); + await this.testSubjects.existOrFail('visualizeCancelAndReturnButton'); + await this.testSubjects.click('visualizeCancelAndReturnButton'); + if (showConfirmModal) { + await this.retry.waitFor( + 'confirm modal to show', + async () => await this.testSubjects.exists('appLeaveConfirmModal') + ); + await this.testSubjects.exists('confirmModalConfirmButton'); + await this.testSubjects.click('confirmModalConfirmButton'); + } + } } diff --git a/test/functional/services/combo_box.ts b/test/functional/services/combo_box.ts index a198aec1d16960..6706db82ce7086 100644 --- a/test/functional/services/combo_box.ts +++ b/test/functional/services/combo_box.ts @@ -21,7 +21,7 @@ export class ComboBoxService extends FtrService { private readonly log = this.ctx.getService('log'); private readonly retry = this.ctx.getService('retry'); private readonly browser = this.ctx.getService('browser'); - private readonly PageObjects = this.ctx.getPageObjects(['common']); + private readonly common = this.ctx.getPageObject('common'); private readonly WAIT_FOR_EXISTS_TIME: number = this.config.get('timeouts.waitForExists'); @@ -113,7 +113,7 @@ export class ComboBoxService extends FtrService { this.log.debug(`comboBox.setCustom, comboBoxSelector: ${comboBoxSelector}, value: ${value}`); const comboBoxElement = await this.testSubjects.find(comboBoxSelector); await this.setFilterValue(comboBoxElement, value); - await this.PageObjects.common.pressEnterKey(); + await this.common.pressEnterKey(); await this.closeOptionsList(comboBoxElement); } diff --git a/test/functional/services/dashboard/add_panel.ts b/test/functional/services/dashboard/add_panel.ts index 98e947541b52d7..43ab1f966bc9a0 100644 --- a/test/functional/services/dashboard/add_panel.ts +++ b/test/functional/services/dashboard/add_panel.ts @@ -13,20 +13,21 @@ export class DashboardAddPanelService extends FtrService { private readonly retry = this.ctx.getService('retry'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly flyout = this.ctx.getService('flyout'); - private readonly PageObjects = this.ctx.getPageObjects(['header', 'common']); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); async clickOpenAddPanel() { this.log.debug('DashboardAddPanel.clickOpenAddPanel'); await this.testSubjects.click('dashboardAddPanelButton'); // Give some time for the animation to complete - await this.PageObjects.common.sleep(500); + await this.common.sleep(500); } async clickCreateNewLink() { this.log.debug('DashboardAddPanel.clickAddNewPanelButton'); await this.testSubjects.click('dashboardAddNewPanelButton'); // Give some time for the animation to complete - await this.PageObjects.common.sleep(500); + await this.common.sleep(500); } async clickQuickButton(visType: string) { @@ -94,7 +95,7 @@ export class DashboardAddPanelService extends FtrService { } await embeddableRows[i].click(); - await this.PageObjects.common.closeToast(); + await this.common.closeToast(); embeddableList.push(name); } }); @@ -104,7 +105,7 @@ export class DashboardAddPanelService extends FtrService { async clickPagerNextButton() { // Clear all toasts that could hide pagination controls - await this.PageObjects.common.clearAllToasts(); + await this.common.clearAllToasts(); const isNext = await this.testSubjects.exists('pagination-button-next'); if (!isNext) { @@ -118,9 +119,9 @@ export class DashboardAddPanelService extends FtrService { return false; } - await this.PageObjects.header.waitUntilLoadingHasFinished(); + await this.header.waitUntilLoadingHasFinished(); await pagerNextButton.click(); - await this.PageObjects.header.waitUntilLoadingHasFinished(); + await this.header.waitUntilLoadingHasFinished(); return true; } diff --git a/test/functional/services/dashboard/expectations.ts b/test/functional/services/dashboard/expectations.ts index 34a4a9de7899a9..c22eddb032cf9e 100644 --- a/test/functional/services/dashboard/expectations.ts +++ b/test/functional/services/dashboard/expectations.ts @@ -16,20 +16,21 @@ export class DashboardExpectService extends FtrService { private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly find = this.ctx.getService('find'); private readonly filterBar = this.ctx.getService('filterBar'); - private readonly PageObjects = this.ctx.getPageObjects(['dashboard', 'visualize', 'visChart']); + private readonly dashboard = this.ctx.getPageObject('dashboard'); + private readonly visChart = this.ctx.getPageObject('visChart'); private readonly findTimeout = 2500; async panelCount(expectedCount: number) { this.log.debug(`DashboardExpect.panelCount(${expectedCount})`); await this.retry.try(async () => { - const panelCount = await this.PageObjects.dashboard.getPanelCount(); + const panelCount = await this.dashboard.getPanelCount(); expect(panelCount).to.be(expectedCount); }); } async visualizationsArePresent(vizList: string[]) { this.log.debug('Checking all visualisations are present on dashsboard'); - let notLoaded = await this.PageObjects.dashboard.getNotLoadedVisualizations(vizList); + let notLoaded = await this.dashboard.getNotLoadedVisualizations(vizList); // TODO: Determine issue occasionally preventing 'geo map' from loading notLoaded = notLoaded.filter((x) => x !== 'Rendering Test: geo map'); expect(notLoaded).to.be.empty(); @@ -231,7 +232,7 @@ export class DashboardExpectService extends FtrService { async dataTableRowCount(expectedCount: number) { this.log.debug(`DashboardExpect.dataTableRowCount(${expectedCount})`); await this.retry.try(async () => { - const dataTableRows = await this.PageObjects.visChart.getTableVisContent(); + const dataTableRows = await this.visChart.getTableVisContent(); expect(dataTableRows.length).to.be(expectedCount); }); } @@ -239,7 +240,7 @@ export class DashboardExpectService extends FtrService { async dataTableNoResult() { this.log.debug(`DashboardExpect.dataTableNoResult`); await this.retry.try(async () => { - await this.PageObjects.visChart.getTableVisNoResult(); + await this.visChart.getTableVisNoResult(); }); } diff --git a/test/functional/services/dashboard/panel_actions.ts b/test/functional/services/dashboard/panel_actions.ts index e7c028acc0e1bf..9aca790b0b4379 100644 --- a/test/functional/services/dashboard/panel_actions.ts +++ b/test/functional/services/dashboard/panel_actions.ts @@ -25,7 +25,9 @@ export class DashboardPanelActionsService extends FtrService { private readonly log = this.ctx.getService('log'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly inspector = this.ctx.getService('inspector'); - private readonly PageObjects = this.ctx.getPageObjects(['header', 'common', 'dashboard']); + private readonly header = this.ctx.getPageObject('header'); + private readonly common = this.ctx.getPageObject('common'); + private readonly dashboard = this.ctx.getPageObject('dashboard'); async findContextMenu(parent?: WebElementWrapper) { return parent @@ -78,8 +80,8 @@ export class DashboardPanelActionsService extends FtrService { const isActionVisible = await this.testSubjects.exists(EDIT_PANEL_DATA_TEST_SUBJ); if (!isActionVisible) await this.clickContextMenuMoreItem(); await this.testSubjects.clickWhenNotDisabled(EDIT_PANEL_DATA_TEST_SUBJ); - await this.PageObjects.header.waitUntilLoadingHasFinished(); - await this.PageObjects.common.waitForTopNavToBeVisible(); + await this.header.waitUntilLoadingHasFinished(); + await this.common.waitForTopNavToBeVisible(); } async editPanelByTitle(title?: string) { @@ -146,7 +148,7 @@ export class DashboardPanelActionsService extends FtrService { await this.openContextMenu(); } await this.testSubjects.click(CLONE_PANEL_DATA_TEST_SUBJ); - await this.PageObjects.dashboard.waitForRenderComplete(); + await this.dashboard.waitForRenderComplete(); } async openCopyToModalByTitle(title?: string) { diff --git a/test/functional/services/dashboard/visualizations.ts b/test/functional/services/dashboard/visualizations.ts index a6b88802d7b814..8688d375f7a7b9 100644 --- a/test/functional/services/dashboard/visualizations.ts +++ b/test/functional/services/dashboard/visualizations.ts @@ -13,25 +13,23 @@ export class DashboardVisualizationsService extends FtrService { private readonly queryBar = this.ctx.getService('queryBar'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly dashboardAddPanel = this.ctx.getService('dashboardAddPanel'); - private readonly PageObjects = this.ctx.getPageObjects([ - 'dashboard', - 'visualize', - 'visEditor', - 'header', - 'discover', - 'timePicker', - ]); + private readonly dashboard = this.ctx.getPageObject('dashboard'); + private readonly visualize = this.ctx.getPageObject('visualize'); + private readonly visEditor = this.ctx.getPageObject('visEditor'); + private readonly header = this.ctx.getPageObject('header'); + private readonly discover = this.ctx.getPageObject('discover'); + private readonly timePicker = this.ctx.getPageObject('timePicker'); async createAndAddTSVBVisualization(name: string) { this.log.debug(`createAndAddTSVBVisualization(${name})`); - const inViewMode = await this.PageObjects.dashboard.getIsInViewMode(); + const inViewMode = await this.dashboard.getIsInViewMode(); if (inViewMode) { - await this.PageObjects.dashboard.switchToEditMode(); + await this.dashboard.switchToEditMode(); } await this.dashboardAddPanel.clickEditorMenuButton(); await this.dashboardAddPanel.clickAddNewEmbeddableLink('metrics'); - await this.PageObjects.visualize.clickVisualBuilder(); - await this.PageObjects.visualize.saveVisualizationExpectSuccess(name); + await this.visualize.clickVisualBuilder(); + await this.visualize.saveVisualizationExpectSuccess(name); } async createSavedSearch({ @@ -44,8 +42,8 @@ export class DashboardVisualizationsService extends FtrService { fields?: string[]; }) { this.log.debug(`createSavedSearch(${name})`); - await this.PageObjects.header.clickDiscover(true); - await this.PageObjects.timePicker.setHistoricalDataRange(); + await this.header.clickDiscover(true); + await this.timePicker.setHistoricalDataRange(); if (query) { await this.queryBar.setQuery(query); @@ -54,12 +52,12 @@ export class DashboardVisualizationsService extends FtrService { if (fields) { for (let i = 0; i < fields.length; i++) { - await this.PageObjects.discover.clickFieldListItemAdd(fields[i]); + await this.discover.clickFieldListItemAdd(fields[i]); } } - await this.PageObjects.discover.saveSearch(name); - await this.PageObjects.header.waitUntilLoadingHasFinished(); + await this.discover.saveSearch(name); + await this.header.waitUntilLoadingHasFinished(); await this.testSubjects.exists('saveSearchSuccess'); } @@ -75,25 +73,25 @@ export class DashboardVisualizationsService extends FtrService { this.log.debug(`createAndAddSavedSearch(${name})`); await this.createSavedSearch({ name, query, fields }); - await this.PageObjects.header.clickDashboard(); + await this.header.clickDashboard(); - const inViewMode = await this.PageObjects.dashboard.getIsInViewMode(); + const inViewMode = await this.dashboard.getIsInViewMode(); if (inViewMode) { - await this.PageObjects.dashboard.switchToEditMode(); + await this.dashboard.switchToEditMode(); } await this.dashboardAddPanel.addSavedSearch(name); } async createAndAddMarkdown({ name, markdown }: { name: string; markdown: string }) { this.log.debug(`createAndAddMarkdown(${markdown})`); - const inViewMode = await this.PageObjects.dashboard.getIsInViewMode(); + const inViewMode = await this.dashboard.getIsInViewMode(); if (inViewMode) { - await this.PageObjects.dashboard.switchToEditMode(); + await this.dashboard.switchToEditMode(); } await this.dashboardAddPanel.clickMarkdownQuickButton(); - await this.PageObjects.visEditor.setMarkdownTxt(markdown); - await this.PageObjects.visEditor.clickGo(); - await this.PageObjects.visualize.saveVisualizationExpectSuccess(name, { + await this.visEditor.setMarkdownTxt(markdown); + await this.visEditor.clickGo(); + await this.visualize.saveVisualizationExpectSuccess(name, { saveAsNew: false, redirectToOrigin: true, }); @@ -101,9 +99,9 @@ export class DashboardVisualizationsService extends FtrService { async createAndEmbedMetric(name: string) { this.log.debug(`createAndEmbedMetric(${name})`); - const inViewMode = await this.PageObjects.dashboard.getIsInViewMode(); + const inViewMode = await this.dashboard.getIsInViewMode(); if (inViewMode) { - await this.PageObjects.dashboard.switchToEditMode(); + await this.dashboard.switchToEditMode(); } await this.dashboardAddPanel.clickEditorMenuButton(); await this.dashboardAddPanel.clickAggBasedVisualizations(); @@ -115,13 +113,13 @@ export class DashboardVisualizationsService extends FtrService { async createAndEmbedMarkdown({ name, markdown }: { name: string; markdown: string }) { this.log.debug(`createAndEmbedMarkdown(${markdown})`); - const inViewMode = await this.PageObjects.dashboard.getIsInViewMode(); + const inViewMode = await this.dashboard.getIsInViewMode(); if (inViewMode) { - await this.PageObjects.dashboard.switchToEditMode(); + await this.dashboard.switchToEditMode(); } await this.dashboardAddPanel.clickMarkdownQuickButton(); - await this.PageObjects.visEditor.setMarkdownTxt(markdown); - await this.PageObjects.visEditor.clickGo(); + await this.visEditor.setMarkdownTxt(markdown); + await this.visEditor.clickGo(); await this.testSubjects.click('visualizesaveAndReturnButton'); } } diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index f2079c02ef5b5c..f54e7b65a46e2a 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -10,7 +10,7 @@ import { chunk } from 'lodash'; import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from './lib/web_element_wrapper'; -interface TabbedGridData { +export interface TabbedGridData { columns: string[]; rows: string[][]; } @@ -22,7 +22,7 @@ interface SelectOptions { export class DataGridService extends FtrService { private readonly find = this.ctx.getService('find'); private readonly testSubjects = this.ctx.getService('testSubjects'); - private readonly PageObjects = this.ctx.getPageObjects(['common', 'header']); + private readonly header = this.ctx.getPageObject('header'); private readonly retry = this.ctx.getService('retry'); async getDataGridTableData(): Promise { @@ -234,7 +234,7 @@ export class DataGridService extends FtrService { const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); const addInclusiveFilterButton = await this.getAddInclusiveFilterButton(tableDocViewRow); await addInclusiveFilterButton.click(); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async getAddInclusiveFilterButton( @@ -263,7 +263,7 @@ export class DataGridService extends FtrService { const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); const addInclusiveFilterButton = await this.getRemoveInclusiveFilterButton(tableDocViewRow); await addInclusiveFilterButton.click(); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async hasNoResults() { diff --git a/test/functional/services/doc_table.ts b/test/functional/services/doc_table.ts index 6c73faec16b1a9..685f1748d56b28 100644 --- a/test/functional/services/doc_table.ts +++ b/test/functional/services/doc_table.ts @@ -17,7 +17,7 @@ interface SelectOptions { export class DocTableService extends FtrService { private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly retry = this.ctx.getService('retry'); - private readonly PageObjects = this.ctx.getPageObjects(['common', 'header']); + private readonly header = this.ctx.getPageObject('header'); public async getTable(selector?: string) { return await this.testSubjects.find(selector ? selector : 'docTable'); @@ -126,7 +126,7 @@ export class DocTableService extends FtrService { const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); const addInclusiveFilterButton = await this.getAddInclusiveFilterButton(tableDocViewRow); await addInclusiveFilterButton.click(); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async getRemoveInclusiveFilterButton( @@ -142,7 +142,7 @@ export class DocTableService extends FtrService { const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); const addInclusiveFilterButton = await this.getRemoveInclusiveFilterButton(tableDocViewRow); await addInclusiveFilterButton.click(); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async getAddExistsFilterButton( @@ -155,7 +155,7 @@ export class DocTableService extends FtrService { const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); const addInclusiveFilterButton = await this.getAddExistsFilterButton(tableDocViewRow); await addInclusiveFilterButton.click(); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async toggleRowExpanded({ @@ -163,7 +163,7 @@ export class DocTableService extends FtrService { rowIndex = 0, }: SelectOptions = {}): Promise { await this.clickRowToggle({ isAnchorRow, rowIndex }); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); return await this.retry.try(async () => { const row = isAnchorRow ? await this.getAnchorRow() : (await this.getBodyRows())[rowIndex]; const detailsRow = await row.findByXpath( diff --git a/test/functional/services/embedding.ts b/test/functional/services/embedding.ts index e394aff19ab8b6..6d168b00c5447d 100644 --- a/test/functional/services/embedding.ts +++ b/test/functional/services/embedding.ts @@ -11,7 +11,7 @@ import { FtrService } from '../ftr_provider_context'; export class EmbeddingService extends FtrService { private readonly browser = this.ctx.getService('browser'); private readonly log = this.ctx.getService('log'); - private readonly PageObjects = this.ctx.getPageObjects(['header']); + private readonly header = this.ctx.getPageObject('header'); /** * Opens current page in embeded mode @@ -20,6 +20,6 @@ export class EmbeddingService extends FtrService { const currentUrl = await this.browser.getCurrentUrl(); this.log.debug(`Opening in embedded mode: ${currentUrl}`); await this.browser.get(`${currentUrl}&embed=true`); - await this.PageObjects.header.waitUntilLoadingHasFinished(); + await this.header.waitUntilLoadingHasFinished(); } } diff --git a/test/functional/services/filter_bar.ts b/test/functional/services/filter_bar.ts index 5f20d3d4f8b7b5..1d0b85eed3a9c5 100644 --- a/test/functional/services/filter_bar.ts +++ b/test/functional/services/filter_bar.ts @@ -12,7 +12,8 @@ import { FtrService } from '../ftr_provider_context'; export class FilterBarService extends FtrService { private readonly comboBox = this.ctx.getService('comboBox'); private readonly testSubjects = this.ctx.getService('testSubjects'); - private readonly PageObjects = this.ctx.getPageObjects(['common', 'header']); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); /** * Checks if specified filter exists @@ -56,7 +57,7 @@ export class FilterBarService extends FtrService { public async removeFilter(key: string): Promise { await this.testSubjects.click(`~filter & ~filter-key-${key}`); await this.testSubjects.click(`deleteFilter`); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } /** @@ -65,8 +66,8 @@ export class FilterBarService extends FtrService { public async removeAllFilters(): Promise { await this.testSubjects.click('showFilterActions'); await this.testSubjects.click('removeAllFilters'); - await this.PageObjects.header.waitUntilLoadingHasFinished(); - await this.PageObjects.common.waitUntilUrlIncludes('filters:!()'); + await this.header.waitUntilLoadingHasFinished(); + await this.common.waitUntilUrlIncludes('filters:!()'); } /** @@ -77,13 +78,13 @@ export class FilterBarService extends FtrService { public async toggleFilterEnabled(key: string): Promise { await this.testSubjects.click(`~filter & ~filter-key-${key}`); await this.testSubjects.click(`disableFilter`); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async toggleFilterPinned(key: string): Promise { await this.testSubjects.click(`~filter & ~filter-key-${key}`); await this.testSubjects.click(`pinFilter`); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } public async isFilterPinned(key: string): Promise { @@ -141,7 +142,7 @@ export class FilterBarService extends FtrService { } } await this.testSubjects.click('saveFilter'); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } /** @@ -152,7 +153,7 @@ export class FilterBarService extends FtrService { public async clickEditFilter(key: string, value: string): Promise { await this.testSubjects.click(`~filter & ~filter-key-${key} & ~filter-value-${value}`); await this.testSubjects.click(`editFilter`); - await this.PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.header.awaitGlobalLoadingIndicatorHidden(); } /** diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index a509141390f676..26f562799b2974 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -47,7 +47,7 @@ import { ListingTableService } from './listing_table'; import { SavedQueryManagementComponentService } from './saved_query_management_component'; import { KibanaSupertestProvider } from './supertest'; import { MenuToggleService } from './menu_toggle'; -import { MonacoEditorProvider } from './monaco_editor'; +import { MonacoEditorService } from './monaco_editor'; export const services = { ...commonServiceProviders, @@ -84,6 +84,6 @@ export const services = { elasticChart: ElasticChartService, supertest: KibanaSupertestProvider, managementMenu: ManagementMenuService, - monacoEditor: MonacoEditorProvider, + monacoEditor: MonacoEditorService, menuToggle: MenuToggleService, }; diff --git a/test/functional/services/listing_table.ts b/test/functional/services/listing_table.ts index 79678cf7a812b8..1cd4249df5050c 100644 --- a/test/functional/services/listing_table.ts +++ b/test/functional/services/listing_table.ts @@ -17,8 +17,8 @@ export class ListingTableService extends FtrService { private readonly find = this.ctx.getService('find'); private readonly log = this.ctx.getService('log'); private readonly retry = this.ctx.getService('retry'); - private readonly common = this.ctx.getPageObjects(['common']).common; - private readonly header = this.ctx.getPageObjects(['header']).header; + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); private async getSearchFilter() { return await this.testSubjects.find('tableListSearchBox'); diff --git a/test/functional/services/monaco_editor.ts b/test/functional/services/monaco_editor.ts index 4e791e54c4b09c..572606f8964546 100644 --- a/test/functional/services/monaco_editor.ts +++ b/test/functional/services/monaco_editor.ts @@ -6,26 +6,24 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function MonacoEditorProvider({ getService }: FtrProviderContext) { - const retry = getService('retry'); - const browser = getService('browser'); +export class MonacoEditorService extends FtrService { + private readonly retry = this.ctx.getService('retry'); + private readonly browser = this.ctx.getService('browser'); - return new (class MonacoEditor { - public async getCodeEditorValue(nthIndex: number = 0) { - let values: string[] = []; + public async getCodeEditorValue(nthIndex: number = 0) { + let values: string[] = []; - await retry.try(async () => { - values = await browser.execute( - () => - (window as any).MonacoEnvironment.monaco.editor - .getModels() - .map((model: any) => model.getValue()) as string[] - ); - }); + await this.retry.try(async () => { + values = await this.browser.execute( + () => + (window as any).MonacoEnvironment.monaco.editor + .getModels() + .map((model: any) => model.getValue()) as string[] + ); + }); - return values[nthIndex] as string; - } - })(); + return values[nthIndex] as string; + } } diff --git a/test/functional/services/query_bar.ts b/test/functional/services/query_bar.ts index 31586d92d92a9d..f0728f2b022e30 100644 --- a/test/functional/services/query_bar.ts +++ b/test/functional/services/query_bar.ts @@ -13,7 +13,8 @@ export class QueryBarService extends FtrService { private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly retry = this.ctx.getService('retry'); private readonly log = this.ctx.getService('log'); - private readonly PageObjects = this.ctx.getPageObjects(['header', 'common']); + private readonly common = this.ctx.getPageObject('common'); + private readonly header = this.ctx.getPageObject('header'); private readonly find = this.ctx.getService('find'); private readonly browser = this.ctx.getService('browser'); @@ -42,15 +43,15 @@ export class QueryBarService extends FtrService { public async clearQuery(): Promise { await this.setQuery(''); - await this.PageObjects.common.pressTabKey(); // move outside of input into language switcher - await this.PageObjects.common.pressTabKey(); // move outside of language switcher so time picker appears + await this.common.pressTabKey(); // move outside of input into language switcher + await this.common.pressTabKey(); // move outside of language switcher so time picker appears } public async submitQuery(): Promise { this.log.debug('QueryBar.submitQuery'); await this.testSubjects.click('queryInput'); - await this.PageObjects.common.pressEnterKey(); - await this.PageObjects.header.waitUntilLoadingHasFinished(); + await this.common.pressEnterKey(); + await this.header.waitUntilLoadingHasFinished(); } public async clickQuerySubmitButton(): Promise { diff --git a/test/functional/services/remote/prevent_parallel_calls.ts b/test/functional/services/remote/prevent_parallel_calls.ts index d21abc9d268674..338bfbd4278736 100644 --- a/test/functional/services/remote/prevent_parallel_calls.ts +++ b/test/functional/services/remote/prevent_parallel_calls.ts @@ -6,44 +6,49 @@ * Side Public License, v 1. */ -export function preventParallelCalls( - fn: (this: C, arg: A) => Promise, - filter: (arg: A) => boolean -) { - const execQueue: Task[] = []; +class Task { + public promise: Promise; + private resolve!: (result: R) => void; + private reject!: (error: Error) => void; - class Task { - public promise: Promise; - private resolve!: (result: R) => void; - private reject!: (error: Error) => void; - - constructor(private readonly context: C, private readonly arg: A) { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - } + constructor( + private readonly execQueue: Array>, + private readonly fn: (this: C, arg: A) => Promise, + private readonly context: C, + private readonly arg: A + ) { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } - public async exec() { - try { - this.resolve(await fn.call(this.context, this.arg)); - } catch (error) { - this.reject(error); - } finally { - execQueue.shift(); - if (execQueue.length) { - execQueue[0].exec(); - } + public async exec() { + try { + this.resolve(await this.fn.call(this.context, this.arg)); + } catch (error) { + this.reject(error); + } finally { + this.execQueue.shift(); + if (this.execQueue.length) { + this.execQueue[0].exec(); } } } +} + +export function preventParallelCalls( + fn: (this: C, arg: A) => Promise, + filter: (arg: A) => boolean +) { + const execQueue: Array> = []; return async function (this: C, arg: A) { if (filter(arg)) { return await fn.call(this, arg); } - const task = new Task(this, arg); + const task = new Task(execQueue, fn, this, arg); if (execQueue.push(task) === 1) { // only item in the queue, kick it off task.exec(); diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index aabe8c0aebb0c6..decf1618c78793 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -14,7 +14,7 @@ export class SavedQueryManagementComponentService extends FtrService { private readonly queryBar = this.ctx.getService('queryBar'); private readonly retry = this.ctx.getService('retry'); private readonly config = this.ctx.getService('config'); - private readonly PageObjects = this.ctx.getPageObjects(['common']); + private readonly common = this.ctx.getPageObject('common'); public async getCurrentlyLoadedQueryID() { await this.openSavedQueryManagementComponent(); @@ -93,7 +93,7 @@ export class SavedQueryManagementComponentService extends FtrService { public async deleteSavedQuery(title: string) { await this.openSavedQueryManagementComponent(); await this.testSubjects.click(`~delete-saved-query-${title}-button`); - await this.PageObjects.common.clickConfirmOnModal(); + await this.common.clickConfirmOnModal(); } async clearCurrentlyLoadedQuery() { diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts index f51492d29b4506..99e0bb6ac4c4c6 100644 --- a/test/functional/services/visualizations/pie_chart.ts +++ b/test/functional/services/visualizations/pie_chart.ts @@ -20,16 +20,16 @@ export class PieChartService extends FtrService { private readonly find = this.ctx.getService('find'); private readonly panelActions = this.ctx.getService('dashboardPanelActions'); private readonly defaultFindTimeout = this.config.get('timeouts.find'); - private readonly pageObjects = this.ctx.getPageObjects(['visChart']); + private readonly visChart = this.ctx.getPageObject('visChart'); private readonly filterActionText = 'Apply filter to current view'; async clickOnPieSlice(name?: string) { this.log.debug(`PieChart.clickOnPieSlice(${name})`); - if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; let sliceLabel = name || slices[0].name; if (name === 'Other') { sliceLabel = '__other__'; @@ -87,10 +87,10 @@ export class PieChartService extends FtrService { async getPieSliceStyle(name: string) { this.log.debug(`VisualizePage.getPieSliceStyle(${name})`); - if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; const selectedSlice = slices.filter((slice) => { return slice.name.toString() === name.replace(',', ''); }); @@ -102,10 +102,10 @@ export class PieChartService extends FtrService { async getAllPieSliceStyles(name: string) { this.log.debug(`VisualizePage.getAllPieSliceStyles(${name})`); - if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; const selectedSlice = slices.filter((slice) => { return slice.name.toString() === name.replace(',', ''); }); @@ -129,10 +129,10 @@ export class PieChartService extends FtrService { } async getPieChartLabels() { - if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; return slices.map((slice) => { if (slice.name === '__missing__') { return 'Missing'; @@ -155,10 +155,10 @@ export class PieChartService extends FtrService { async getPieSliceCount() { this.log.debug('PieChart.getPieSliceCount'); - if (await this.pageObjects.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(pieChartSelector)) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; return slices?.length; } const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); @@ -167,8 +167,8 @@ export class PieChartService extends FtrService { async expectPieSliceCountEsCharts(expectedCount: number) { const slices = - (await this.pageObjects.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0] - ?.partitions ?? []; + (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? + []; expect(slices.length).to.be(expectedCount); } diff --git a/test/plugin_functional/plugins/core_app_status/tsconfig.json b/test/plugin_functional/plugins/core_app_status/tsconfig.json index 4d979fbf7f15fd..0f0e7caf2b486a 100644 --- a/test/plugin_functional/plugins/core_app_status/tsconfig.json +++ b/test/plugin_functional/plugins/core_app_status/tsconfig.json @@ -1,8 +1,11 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { + "composite": true, "outDir": "./target", - "skipLibCheck": true + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true }, "include": [ "index.ts", @@ -10,5 +13,8 @@ "public/**/*.tsx", "../../../../typings/**/*", ], - "exclude": [] + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" }, + ], } diff --git a/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json b/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json index eacd2f5e9aee3e..d0d1f2d99295ab 100644 --- a/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json +++ b/test/plugin_functional/plugins/core_provider_plugin/tsconfig.json @@ -1,8 +1,11 @@ { "extends": "../../../../tsconfig.base.json", "compilerOptions": { + "composite": true, "outDir": "./target", - "skipLibCheck": true + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true }, "include": [ "index.ts", @@ -12,6 +15,6 @@ ], "exclude": [], "references": [ - { "path": "../../../../src/core/tsconfig.json" } - ] + { "path": "../../../../src/core/tsconfig.json" }, + ], } diff --git a/test/tsconfig.json b/test/tsconfig.json index 86b97da699ae11..2524755d3f291c 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "../tsconfig.base.json", "compilerOptions": { - "incremental": false, + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, "types": ["node", "resize-observer-polyfill"] }, "include": [ @@ -9,7 +13,7 @@ "../typings/**/*", "../packages/kbn-test/types/ftr_globals/**/*" ], - "exclude": ["plugin_functional/plugins/**/*", "interpreter_functional/plugins/**/*"], + "exclude": ["target/**/*", "plugin_functional/plugins/**/*", "interpreter_functional/plugins/**/*"], "references": [ { "path": "../src/core/tsconfig.json" }, { "path": "../src/plugins/telemetry_management_section/tsconfig.json" }, @@ -40,6 +44,9 @@ { "path": "../src/plugins/url_forwarding/tsconfig.json" }, { "path": "../src/plugins/usage_collection/tsconfig.json" }, { "path": "../src/plugins/index_pattern_management/tsconfig.json" }, - { "path": "../src/plugins/legacy_export/tsconfig.json" } + { "path": "../src/plugins/legacy_export/tsconfig.json" }, + { "path": "../src/plugins/visualize/tsconfig.json" }, + { "path": "plugin_functional/plugins/core_app_status/tsconfig.json" }, + { "path": "plugin_functional/plugins/core_provider_plugin/tsconfig.json" }, ] } diff --git a/test/visual_regression/ftr_provider_context.d.ts b/test/visual_regression/ftr_provider_context.ts similarity index 78% rename from test/visual_regression/ftr_provider_context.d.ts rename to test/visual_regression/ftr_provider_context.ts index ba3eb370048b88..28bedd1ca6bc34 100644 --- a/test/visual_regression/ftr_provider_context.d.ts +++ b/test/visual_regression/ftr_provider_context.ts @@ -6,9 +6,10 @@ * Side Public License, v 1. */ -import { GenericFtrProviderContext } from '@kbn/test'; +import { GenericFtrProviderContext, GenericFtrService } from '@kbn/test'; import { pageObjects } from '../functional/page_objects'; import { services } from './services'; export type FtrProviderContext = GenericFtrProviderContext; +export class FtrService extends GenericFtrService {} diff --git a/test/visual_regression/services/index.ts b/test/visual_regression/services/index.ts index a948e4ef5d94ed..9aefe1f8de7809 100644 --- a/test/visual_regression/services/index.ts +++ b/test/visual_regression/services/index.ts @@ -7,9 +7,9 @@ */ import { services as functionalServices } from '../../functional/services'; -import { VisualTestingProvider } from './visual_testing'; +import { VisualTestingService } from './visual_testing'; export const services = { ...functionalServices, - visualTesting: VisualTestingProvider, + visualTesting: VisualTestingService, }; diff --git a/test/visual_regression/services/visual_testing/index.ts b/test/visual_regression/services/visual_testing/index.ts index 9add3a7f6fd332..156e3814d8a1d0 100644 --- a/test/visual_regression/services/visual_testing/index.ts +++ b/test/visual_regression/services/visual_testing/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { VisualTestingProvider } from './visual_testing'; +export * from './visual_testing'; diff --git a/test/visual_regression/services/visual_testing/visual_testing.ts b/test/visual_regression/services/visual_testing/visual_testing.ts index a0d9afa90f3fe6..59c601e6a2b6e6 100644 --- a/test/visual_regression/services/visual_testing/visual_testing.ts +++ b/test/visual_regression/services/visual_testing/visual_testing.ts @@ -10,7 +10,7 @@ import { postSnapshot } from '@percy/agent/dist/utils/sdk-utils'; import testSubjSelector from '@kbn/test-subj-selector'; import { Test } from '@kbn/test'; import { kibanaPackageJson as pkg } from '@kbn/utils'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrService, FtrProviderContext } from '../../ftr_provider_context'; // @ts-ignore internal js that is passed to the browser as is import { takePercySnapshot, takePercySnapshotWithAgent } from './take_percy_snapshot'; @@ -34,79 +34,81 @@ export interface SnapshotOptions { hide?: string[]; } -export async function VisualTestingProvider({ getService }: FtrProviderContext) { - const browser = getService('browser'); - const log = getService('log'); - const lifecycle = getService('lifecycle'); +const statsCache = new WeakMap(); - let currentTest: Test | undefined; - lifecycle.beforeEachTest.add((test) => { - currentTest = test; - }); +function getStats(test: Test) { + if (!statsCache.has(test)) { + statsCache.set(test, { + snapshotCount: 0, + }); + } + + return statsCache.get(test)!; +} - const statsCache = new WeakMap(); +export class VisualTestingService extends FtrService { + private readonly browser = this.ctx.getService('browser'); + private readonly log = this.ctx.getService('log'); - function getStats(test: Test) { - if (!statsCache.has(test)) { - statsCache.set(test, { - snapshotCount: 0, - }); - } + private currentTest: Test | undefined; - return statsCache.get(test)!; + constructor(ctx: FtrProviderContext) { + super(ctx); + + this.ctx.getService('lifecycle').beforeEachTest.add((test) => { + this.currentTest = test; + }); } - return new (class VisualTesting { - public async snapshot(options: SnapshotOptions = {}) { - if (process.env.DISABLE_VISUAL_TESTING) { - log.warning( - 'Capturing of percy snapshots disabled, would normally capture a snapshot here!' - ); - return; - } - - log.debug('Capturing percy snapshot'); - - if (!currentTest) { - throw new Error('unable to determine current test'); - } - - const [domSnapshot, url] = await Promise.all([ - this.getSnapshot(options.show, options.hide), - browser.getCurrentUrl(), - ]); - const stats = getStats(currentTest); - stats.snapshotCount += 1; - - const { name } = options; - const success = await postSnapshot({ - name: `${currentTest.fullTitle()} [${name ? name : stats.snapshotCount}]`, - url, - domSnapshot, - clientInfo: `kibana-ftr:${pkg.version}`, - ...DEFAULT_OPTIONS, - }); - - if (!success) { - throw new Error('Percy snapshot failed'); - } + public async snapshot(options: SnapshotOptions = {}) { + if (process.env.DISABLE_VISUAL_TESTING) { + this.log.warning( + 'Capturing of percy snapshots disabled, would normally capture a snapshot here!' + ); + return; } - private async getSnapshot(show: string[] = [], hide: string[] = []) { - const showSelectors = show.map(testSubjSelector); - const hideSelectors = hide.map(testSubjSelector); - const snapshot = await browser.execute<[string[], string[]], string | false>( - takePercySnapshot, - showSelectors, - hideSelectors - ); - return snapshot !== false - ? snapshot - : await browser.execute<[string[], string[]], string>( - takePercySnapshotWithAgent, - showSelectors, - hideSelectors - ); + this.log.debug('Capturing percy snapshot'); + + if (!this.currentTest) { + throw new Error('unable to determine current test'); + } + + const [domSnapshot, url] = await Promise.all([ + this.getSnapshot(options.show, options.hide), + this.browser.getCurrentUrl(), + ]); + const stats = getStats(this.currentTest); + stats.snapshotCount += 1; + + const { name } = options; + const success = await postSnapshot({ + name: `${this.currentTest.fullTitle()} [${name ? name : stats.snapshotCount}]`, + url, + domSnapshot, + clientInfo: `kibana-ftr:${pkg.version}`, + ...DEFAULT_OPTIONS, + }); + + if (!success) { + throw new Error('Percy snapshot failed'); } - })(); + } + + private async getSnapshot(show: string[] = [], hide: string[] = []) { + const showSelectors = show.map(testSubjSelector); + const hideSelectors = hide.map(testSubjSelector); + const snapshot = await this.browser.execute<[string[], string[]], string | false>( + takePercySnapshot, + showSelectors, + hideSelectors + ); + return snapshot !== false + ? snapshot + : await this.browser.execute<[string[], string[]], string>( + takePercySnapshotWithAgent, + showSelectors, + hideSelectors + ); + } } diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 9aa41cb9bc7559..a2c1ee43a92c46 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -56,6 +56,7 @@ { "path": "./src/plugins/visualizations/tsconfig.json" }, { "path": "./src/plugins/visualize/tsconfig.json" }, { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, + { "path": "./test/tsconfig.json" }, { "path": "./x-pack/plugins/actions/tsconfig.json" }, { "path": "./x-pack/plugins/alerting/tsconfig.json" }, { "path": "./x-pack/plugins/apm/tsconfig.json" }, From 9a275de0f99cb616048cdb1409eb1018daf4b196 Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Fri, 4 Jun 2021 14:28:11 -0400 Subject: [PATCH 76/77] [App Search] Initial logic for Crawler Overview (#101176) * New CrawlerOverview component * CrawlerRouter should use CrawlerOverview in dev mode * New CrawlerOverviewLogic * New crawler route * Display domains data for CrawlerOverview in EuiCode * Update types * Clean up tests for Crawler utils * Better todo commenting for CrawlerOverview tests * Remove unused div from CrawlerOverview * Rename CrawlerOverviewLogic.actios.setCrawlerData to onFetchCrawlerData * Cleaning up CrawlerOverviewLogic * Cleaning up CrawlerOverviewLogic tests * Fix CrawlerPolicies capitalization * Add Loading UX * Cleaning up afterEachs across Crawler tests --- .../crawler/crawler_landing.test.tsx | 5 +- .../crawler/crawler_overview.test.tsx | 66 ++++++++++ .../components/crawler/crawler_overview.tsx | 41 ++++++ .../crawler/crawler_overview_logic.test.ts | 121 ++++++++++++++++++ .../crawler/crawler_overview_logic.ts | 64 +++++++++ .../crawler/crawler_router.test.tsx | 15 ++- .../components/crawler/crawler_router.tsx | 3 +- .../app_search/components/crawler/types.ts | 67 ++++++++++ .../components/crawler/utils.test.ts | 93 ++++++++++++++ .../app_search/components/crawler/utils.ts | 55 ++++++++ .../server/routes/app_search/crawler.test.ts | 35 +++++ .../server/routes/app_search/crawler.ts | 29 +++++ .../server/routes/app_search/index.ts | 2 + 13 files changed, 589 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts 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 index 9591b82773b9fe..132579bad8bdc0 100644 --- 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 @@ -19,14 +19,11 @@ describe('CrawlerLanding', () => { let wrapper: ShallowWrapper; beforeEach(() => { + jest.clearAllMocks(); setMockValues({ ...mockEngineValues }); wrapper = shallow(); }); - afterEach(() => { - jest.clearAllMocks(); - }); - it('contains an external documentation link', () => { const externalDocumentationLink = wrapper.find('[data-test-subj="CrawlerDocumentationLink"]'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx new file mode 100644 index 00000000000000..eb30ae867b4b6d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { rerender, setMockActions, setMockValues } from '../../../__mocks__'; +import '../../../__mocks__/shallow_useeffect.mock'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiCode } from '@elastic/eui'; + +import { Loading } from '../../../shared/loading'; + +import { CrawlerOverview } from './crawler_overview'; + +const actions = { + fetchCrawlerData: jest.fn(), +}; + +const values = { + dataLoading: false, + domains: [], +}; + +describe('CrawlerOverview', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + wrapper = shallow(); + }); + + it('renders', () => { + expect(wrapper.find(EuiCode)).toHaveLength(1); + }); + + it('calls fetchCrawlerData on page load', () => { + expect(actions.fetchCrawlerData).toHaveBeenCalledTimes(1); + }); + + // TODO after DomainsTable is built in a future PR + // it('contains a DomainsTable', () => {}) + + // TODO after CrawlRequestsTable is built in a future PR + // it('containss a CrawlRequestsTable,() => {}) + + // TODO after AddDomainForm is built in a future PR + // it('contains an AddDomainForm' () => {}) + + // TODO after empty state is added in a future PR + // it('has an empty state', () => {} ) + + it('shows an empty state when data is loading', () => { + setMockValues({ dataLoading: true }); + rerender(wrapper); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx new file mode 100644 index 00000000000000..5eeaaaef696058 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiCode, EuiPageHeader } from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; + +import { Loading } from '../../../shared/loading'; + +import { CRAWLER_TITLE } from './constants'; +import { CrawlerOverviewLogic } from './crawler_overview_logic'; + +export const CrawlerOverview: React.FC = () => { + const { dataLoading, domains } = useValues(CrawlerOverviewLogic); + + const { fetchCrawlerData } = useActions(CrawlerOverviewLogic); + + useEffect(() => { + fetchCrawlerData(); + }, []); + + if (dataLoading) { + return ; + } + + return ( + <> + + + {JSON.stringify(domains, null, 2)} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts new file mode 100644 index 00000000000000..766f5dcfa02dc3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.test.ts @@ -0,0 +1,121 @@ +/* + * 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 { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { CrawlerOverviewLogic } from './crawler_overview_logic'; +import { CrawlerPolicies, CrawlerRules, CrawlRule } from './types'; + +const DEFAULT_VALUES = { + dataLoading: true, + domains: [], +}; + +const DEFAULT_CRAWL_RULE: CrawlRule = { + id: '-', + policy: CrawlerPolicies.allow, + rule: CrawlerRules.regex, + pattern: '.*', +}; + +describe('CrawlerOverviewLogic', () => { + const { mount } = new LogicMounter(CrawlerOverviewLogic); + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(CrawlerOverviewLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onFetchCrawlerData', () => { + const crawlerData = { + domains: [ + { + id: '507f1f77bcf86cd799439011', + createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', + url: 'moviedatabase.com', + documentCount: 13, + sitemaps: [], + entryPoints: [], + crawlRules: [], + defaultCrawlRule: DEFAULT_CRAWL_RULE, + }, + ], + }; + + beforeEach(() => { + CrawlerOverviewLogic.actions.onFetchCrawlerData(crawlerData); + }); + + it('should set all received data as top-level values', () => { + expect(CrawlerOverviewLogic.values.domains).toEqual(crawlerData.domains); + }); + + it('should set dataLoading to false', () => { + expect(CrawlerOverviewLogic.values.dataLoading).toEqual(false); + }); + }); + }); + + describe('listeners', () => { + describe('fetchCrawlerData', () => { + it('calls onFetchCrawlerData with retrieved data that has been converted from server to client', async () => { + jest.spyOn(CrawlerOverviewLogic.actions, 'onFetchCrawlerData'); + + http.get.mockReturnValue( + Promise.resolve({ + domains: [ + { + id: '507f1f77bcf86cd799439011', + name: 'moviedatabase.com', + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + document_count: 13, + sitemaps: [], + entry_points: [], + crawl_rules: [], + }, + ], + }) + ); + CrawlerOverviewLogic.actions.fetchCrawlerData(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/crawler'); + expect(CrawlerOverviewLogic.actions.onFetchCrawlerData).toHaveBeenCalledWith({ + domains: [ + { + id: '507f1f77bcf86cd799439011', + createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', + url: 'moviedatabase.com', + documentCount: 13, + sitemaps: [], + entryPoints: [], + crawlRules: [], + }, + ], + }); + }); + + it('calls flashApiErrors when there is an error', async () => { + http.get.mockReturnValue(Promise.reject('error')); + CrawlerOverviewLogic.actions.fetchCrawlerData(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts new file mode 100644 index 00000000000000..6f04ade5962ebb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview_logic.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors } from '../../../shared/flash_messages'; + +import { HttpLogic } from '../../../shared/http'; +import { EngineLogic } from '../engine'; + +import { CrawlerData, CrawlerDataFromServer, CrawlerDomain } from './types'; +import { crawlerDataServerToClient } from './utils'; + +interface CrawlerOverviewValues { + dataLoading: boolean; + domains: CrawlerDomain[]; +} + +interface CrawlerOverviewActions { + fetchCrawlerData(): void; + onFetchCrawlerData(data: CrawlerData): { data: CrawlerData }; +} + +export const CrawlerOverviewLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'crawler', 'crawler_overview'], + actions: { + fetchCrawlerData: true, + onFetchCrawlerData: (data) => ({ data }), + }, + reducers: { + dataLoading: [ + true, + { + onFetchCrawlerData: () => false, + }, + ], + domains: [ + [], + { + onFetchCrawlerData: (_, { data: { domains } }) => domains, + }, + ], + }, + listeners: ({ actions }) => ({ + fetchCrawlerData: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/crawler`); + const crawlerData = crawlerDataServerToClient(response as CrawlerDataFromServer); + actions.onFetchCrawlerData(crawlerData); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); 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 index 6aa9ca8c4feb1d..351f5474478033 100644 --- 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 @@ -14,21 +14,32 @@ import { Switch } from 'react-router-dom'; import { shallow } from 'enzyme'; import { CrawlerLanding } from './crawler_landing'; +import { CrawlerOverview } from './crawler_overview'; import { CrawlerRouter } from './crawler_router'; describe('CrawlerRouter', () => { + const OLD_ENV = process.env; + beforeEach(() => { + jest.clearAllMocks(); setMockValues({ ...mockEngineValues }); }); afterEach(() => { - jest.clearAllMocks(); + process.env = OLD_ENV; }); - it('renders a landing page', () => { + it('renders a landing page by default', () => { const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); expect(wrapper.find(CrawlerLanding)).toHaveLength(1); }); + + it('renders a crawler overview in dev', () => { + process.env.NODE_ENV = 'development'; + const wrapper = shallow(); + + expect(wrapper.find(CrawlerOverview)).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 index fcc949de7d8b4b..926c45b4379377 100644 --- 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 @@ -14,13 +14,14 @@ import { getEngineBreadcrumbs } from '../engine'; import { CRAWLER_TITLE } from './constants'; import { CrawlerLanding } from './crawler_landing'; +import { CrawlerOverview } from './crawler_overview'; export const CrawlerRouter: React.FC = () => { return ( - + {process.env.NODE_ENV === 'development' ? : } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts new file mode 100644 index 00000000000000..f895e8f01e399f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum CrawlerPolicies { + allow = 'allow', + deny = 'deny', +} + +export enum CrawlerRules { + beginsWith = 'begins', + endsWith = 'ends', + contains = 'contains', + regex = 'regex', +} + +export interface CrawlRule { + id: string; + policy: CrawlerPolicies; + rule: CrawlerRules; + pattern: string; +} + +export interface EntryPoint { + id: string; + value: string; +} + +export interface Sitemap { + id: string; + url: string; +} + +export interface CrawlerDomain { + createdOn: string; + documentCount: number; + id: string; + lastCrawl?: string; + url: string; + crawlRules: CrawlRule[]; + defaultCrawlRule?: CrawlRule; + entryPoints: EntryPoint[]; + sitemaps: Sitemap[]; +} + +export interface CrawlerDomainFromServer { + id: string; + name: string; + created_on: string; + last_visited_at?: string; + document_count: number; + crawl_rules: CrawlRule[]; + default_crawl_rule?: CrawlRule; + entry_points: EntryPoint[]; + sitemaps: Sitemap[]; +} + +export interface CrawlerData { + domains: CrawlerDomain[]; +} + +export interface CrawlerDataFromServer { + domains: CrawlerDomainFromServer[]; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts new file mode 100644 index 00000000000000..6e2dd7c826b70a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CrawlerPolicies, CrawlerRules, CrawlRule, CrawlerDomainFromServer } from './types'; + +import { crawlerDomainServerToClient, crawlerDataServerToClient } from './utils'; + +const DEFAULT_CRAWL_RULE: CrawlRule = { + id: '-', + policy: CrawlerPolicies.allow, + rule: CrawlerRules.regex, + pattern: '.*', +}; + +describe('crawlerDomainServerToClient', () => { + it('converts the API payload into properties matching our code style', () => { + const id = '507f1f77bcf86cd799439011'; + const name = 'moviedatabase.com'; + + const defaultServerPayload = { + id, + name, + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + document_count: 13, + sitemaps: [], + entry_points: [], + crawl_rules: [], + }; + + const defaultClientPayload = { + id, + createdOn: 'Mon, 31 Aug 2020 17:00:00 +0000', + url: name, + documentCount: 13, + sitemaps: [], + entryPoints: [], + crawlRules: [], + }; + + expect(crawlerDomainServerToClient(defaultServerPayload)).toStrictEqual(defaultClientPayload); + expect( + crawlerDomainServerToClient({ + ...defaultServerPayload, + last_visited_at: 'Mon, 31 Aug 2020 17:00:00 +0000', + }) + ).toStrictEqual({ ...defaultClientPayload, lastCrawl: 'Mon, 31 Aug 2020 17:00:00 +0000' }); + expect( + crawlerDomainServerToClient({ + ...defaultServerPayload, + default_crawl_rule: DEFAULT_CRAWL_RULE, + }) + ).toStrictEqual({ ...defaultClientPayload, defaultCrawlRule: DEFAULT_CRAWL_RULE }); + }); +}); + +describe('crawlerDataServerToClient', () => { + it('converts all domains from the server form to their client form', () => { + const domains: CrawlerDomainFromServer[] = [ + { + id: 'x', + name: 'moviedatabase.com', + document_count: 13, + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + sitemaps: [], + entry_points: [], + crawl_rules: [], + default_crawl_rule: DEFAULT_CRAWL_RULE, + }, + { + id: 'y', + name: 'swiftype.com', + last_visited_at: 'Mon, 31 Aug 2020 17:00:00 +0000', + document_count: 40, + created_on: 'Mon, 31 Aug 2020 17:00:00 +0000', + sitemaps: [], + entry_points: [], + crawl_rules: [], + }, + ]; + + const output = crawlerDataServerToClient({ + domains, + }); + + expect(output.domains).toHaveLength(2); + expect(output.domains[0]).toEqual(crawlerDomainServerToClient(domains[0])); + expect(output.domains[1]).toEqual(crawlerDomainServerToClient(domains[1])); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts new file mode 100644 index 00000000000000..e89c549261fcae --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/utils.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CrawlerDomain, + CrawlerDomainFromServer, + CrawlerData, + CrawlerDataFromServer, +} from './types'; + +export function crawlerDomainServerToClient(payload: CrawlerDomainFromServer): CrawlerDomain { + const { + id, + name, + sitemaps, + created_on: createdOn, + last_visited_at: lastCrawl, + document_count: documentCount, + crawl_rules: crawlRules, + default_crawl_rule: defaultCrawlRule, + entry_points: entryPoints, + } = payload; + + const clientPayload: CrawlerDomain = { + id, + url: name, + documentCount, + createdOn, + crawlRules, + sitemaps, + entryPoints, + }; + + if (lastCrawl) { + clientPayload.lastCrawl = lastCrawl; + } + + if (defaultCrawlRule) { + clientPayload.defaultCrawlRule = defaultCrawlRule; + } + + return clientPayload; +} + +export function crawlerDataServerToClient(payload: CrawlerDataFromServer): CrawlerData { + const { domains } = payload; + + return { + domains: domains.map((domain) => crawlerDomainServerToClient(domain)), + }; +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts new file mode 100644 index 00000000000000..626a107b6942ba --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockDependencies, mockRequestHandler, MockRouter } from '../../__mocks__'; + +import { registerCrawlerRoutes } from './crawler'; + +describe('crawler routes', () => { + describe('GET /api/app_search/engines/{name}/crawler', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{name}/crawler', + }); + + registerCrawlerRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:name/crawler', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts new file mode 100644 index 00000000000000..15b8340b07d4eb --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerCrawlerRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{name}/crawler', + validate: { + params: schema.object({ + name: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:name/crawler', + }) + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 6ccdce0935d935..2442b61c632c1a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -9,6 +9,7 @@ import { RouteDependencies } from '../../plugin'; import { registerAnalyticsRoutes } from './analytics'; import { registerApiLogsRoutes } from './api_logs'; +import { registerCrawlerRoutes } from './crawler'; import { registerCredentialsRoutes } from './credentials'; import { registerCurationsRoutes } from './curations'; import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; @@ -42,4 +43,5 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerResultSettingsRoutes(dependencies); registerApiLogsRoutes(dependencies); registerOnboardingRoutes(dependencies); + registerCrawlerRoutes(dependencies); }; From 77533da2be7470238474faf7efe4ce351a014a4e Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Fri, 4 Jun 2021 14:32:17 -0400 Subject: [PATCH 77/77] [App Search] 100% code coverage plus fix console error (#101407) --- .../__mocks__/flash_messages_logic.mock.ts | 1 + .../app_search/components/library/library.tsx | 2 ++ .../applications/app_search/index.test.tsx | 23 +++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts index 17e22e6f23daf3..6c31927cd75b09 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts @@ -15,6 +15,7 @@ export const mockFlashMessagesActions = { clearFlashMessages: jest.fn(), setQueuedMessages: jest.fn(), clearQueuedMessages: jest.fn(), + dismissToastMessage: jest.fn(), }; export const mockFlashMessageHelpers = { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx index 5d619297702994..b9d3dbd9ee4129 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx @@ -5,6 +5,8 @@ * 2.0. */ +/* istanbul ignore file */ + import React, { useState } from 'react'; import { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 287d46c2dec75c..8d33bd2d130ec5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -26,6 +26,7 @@ import { EngineRouter, EngineNav } from './components/engine'; import { EngineCreation } from './components/engine_creation'; import { EnginesOverview } from './components/engines'; import { ErrorConnecting } from './components/error_connecting'; +import { Library } from './components/library'; import { MetaEngineCreation } from './components/meta_engine_creation'; import { RoleMappingsRouter } from './components/role_mappings'; import { SetupGuide } from './components/setup_guide'; @@ -147,6 +148,28 @@ describe('AppSearchConfigured', () => { }); }); }); + + describe('library', () => { + it('renders a library page in development', () => { + const OLD_ENV = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + rerender(wrapper); + + expect(wrapper.find(Library)).toHaveLength(1); + process.env.NODE_ENV = OLD_ENV; + }); + + it("doesn't in production", () => { + const OLD_ENV = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + rerender(wrapper); + + expect(wrapper.find(Library)).toHaveLength(0); + process.env.NODE_ENV = OLD_ENV; + }); + }); }); describe('AppSearchNav', () => {