From 55bd3b3bb80e5ff825e62ede0a882e568ef10ce9 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 9 Sep 2020 10:03:20 -0500 Subject: [PATCH 01/25] Upgrade to Kea 2.2 (#77047) This PR updates Kea from the last RC to the stable release 2.2. --- x-pack/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/package.json b/x-pack/package.json index 899eca1095923..3a074ba1f1d7d 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -195,7 +195,7 @@ "jsdom": "13.1.0", "jsondiffpatch": "0.4.1", "jsts": "^1.6.2", - "kea": "2.2.0-rc.4", + "kea": "^2.2.0", "loader-utils": "^1.2.3", "lz-string": "^1.4.4", "madge": "3.4.4", diff --git a/yarn.lock b/yarn.lock index ddb83b3cf1532..29f99c25b7730 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18194,10 +18194,10 @@ kdbush@^3.0.0: resolved "https://registry.yarnpkg.com/kdbush/-/kdbush-3.0.0.tgz#f8484794d47004cc2d85ed3a79353dbe0abc2bf0" integrity sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew== -kea@2.2.0-rc.4: - version "2.2.0-rc.4" - resolved "https://registry.yarnpkg.com/kea/-/kea-2.2.0-rc.4.tgz#cc0376950530a6751f73387c4b25a39efa1faa77" - integrity sha512-pYuwaCiJkBvHZShi8kqhk8dC4DjeELdK51Lw7Pn0tNdJgZJDF6COhsUiF/yrh9d7woNYDxKfuxH+QWZFfo8PkA== +kea@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/kea/-/kea-2.2.0.tgz#1ba4a174a53880cca8002a67cf62b19b30d09702" + integrity sha512-IzgTC6SC89wTLfiBMPlduG4r4YanxONYK4werz8RMZxPvcYw4XEEK8xQJguwVYtLCEGm4x5YiLCubGqGfRcbEw== kew@~0.1.7: version "0.1.7" From 538bd4be511e8c8a400b0290b8e899361cdbe8d8 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Wed, 9 Sep 2020 11:30:37 -0400 Subject: [PATCH 02/25] fixed typo --- .../saved_objects/public/save_modal/saved_object_save_modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 3b9efbee22ba6..9cdef8b9392bb 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -294,7 +294,7 @@ export class SavedObjectSaveModal extends React.Component id="savedObjects.saveModal.duplicateTitleDescription" defaultMessage="Saving '{title}' creates a duplicate title." values={{ - title: this.props.title, + title: this.state.title, }} />

From c7dac8000cef75bd6420be4e755dfa7dae122f4a Mon Sep 17 00:00:00 2001 From: David Roberts Date: Wed, 9 Sep 2020 16:31:09 +0100 Subject: [PATCH 03/25] [ML] Account for "properties" layer in find_file_structure mappings (#77035) This is the UI side companion for elastic/elasticsearch#62158 Previously the "mappings" field of the response from the find_file_structure endpoint was not a drop-in for the mappings format of the create index endpoint - the "properties" layer was missing. The reason for omitting it initially was that the assumption was that the find_file_structure endpoint would only ever return very simple mappings without any nested objects. However, this will not be true in the future, as we will improve mappings detection for complex JSON objects. As a first step it makes sense to move the returned mappings closer to the standard format. --- x-pack/plugins/ml/common/types/file_datavisualizer.ts | 7 ++++++- .../ml/server/models/file_data_visualizer/import_data.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/ml/common/types/file_datavisualizer.ts b/x-pack/plugins/ml/common/types/file_datavisualizer.ts index c997a4e24f868..a8b775c8d5f60 100644 --- a/x-pack/plugins/ml/common/types/file_datavisualizer.ts +++ b/x-pack/plugins/ml/common/types/file_datavisualizer.ts @@ -84,7 +84,12 @@ export interface Settings { } export interface Mappings { - [key: string]: any; + _meta?: { + created_by: string; + }; + properties: { + [key: string]: any; + }; } export interface IngestPipelineWrapper { diff --git a/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts b/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts index 6108454c08aa7..26dba7c2f00c1 100644 --- a/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts +++ b/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts @@ -94,7 +94,7 @@ export function importDataProvider({ asCurrentUser }: IScopedClusterClient) { _meta: { created_by: INDEX_META_DATA_CREATED_BY, }, - properties: mappings, + properties: mappings.properties, }, }; From 7955a02437989385152c9c201aafb6a52c10aedf Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Wed, 9 Sep 2020 18:57:32 +0300 Subject: [PATCH 04/25] legacy utils cleanup (#76608) * move prompt to cli_keystore * move binder to cli * remove unused path_contains Co-authored-by: Elastic Machine --- src/{legacy/utils => cli/cluster}/binder.ts | 0 .../utils => cli/cluster}/binder_for.ts | 0 src/cli/cluster/worker.ts | 2 +- src/cli_keystore/add.js | 2 +- src/cli_keystore/add.test.js | 2 +- src/cli_keystore/create.js | 2 +- src/cli_keystore/create.test.js | 2 +- .../server => cli_keystore}/utils/index.js | 0 .../server => cli_keystore}/utils/prompt.js | 0 .../utils/prompt.test.js | 0 src/legacy/utils/index.js | 2 -- src/legacy/utils/path_contains.js | 24 ------------------- 12 files changed, 5 insertions(+), 31 deletions(-) rename src/{legacy/utils => cli/cluster}/binder.ts (100%) rename src/{legacy/utils => cli/cluster}/binder_for.ts (100%) rename src/{legacy/server => cli_keystore}/utils/index.js (100%) rename src/{legacy/server => cli_keystore}/utils/prompt.js (100%) rename src/{legacy/server => cli_keystore}/utils/prompt.test.js (100%) delete mode 100644 src/legacy/utils/path_contains.js diff --git a/src/legacy/utils/binder.ts b/src/cli/cluster/binder.ts similarity index 100% rename from src/legacy/utils/binder.ts rename to src/cli/cluster/binder.ts diff --git a/src/legacy/utils/binder_for.ts b/src/cli/cluster/binder_for.ts similarity index 100% rename from src/legacy/utils/binder_for.ts rename to src/cli/cluster/binder_for.ts diff --git a/src/cli/cluster/worker.ts b/src/cli/cluster/worker.ts index 097a549187429..c8a8a067d30bf 100644 --- a/src/cli/cluster/worker.ts +++ b/src/cli/cluster/worker.ts @@ -21,7 +21,7 @@ import _ from 'lodash'; import cluster from 'cluster'; import { EventEmitter } from 'events'; -import { BinderFor } from '../../legacy/utils/binder_for'; +import { BinderFor } from './binder_for'; import { fromRoot } from '../../core/server/utils'; const cliPath = fromRoot('src/cli'); diff --git a/src/cli_keystore/add.js b/src/cli_keystore/add.js index 462259ec942dd..232392f34c63b 100644 --- a/src/cli_keystore/add.js +++ b/src/cli_keystore/add.js @@ -18,7 +18,7 @@ */ import { Logger } from '../cli_plugin/lib/logger'; -import { confirm, question } from '../legacy/server/utils'; +import { confirm, question } from './utils'; import { createPromiseFromStreams, createConcatStream } from '../core/server/utils'; /** diff --git a/src/cli_keystore/add.test.js b/src/cli_keystore/add.test.js index b5d5009667eb4..f1adee8879bc2 100644 --- a/src/cli_keystore/add.test.js +++ b/src/cli_keystore/add.test.js @@ -42,7 +42,7 @@ import { PassThrough } from 'stream'; import { Keystore } from '../legacy/server/keystore'; import { add } from './add'; import { Logger } from '../cli_plugin/lib/logger'; -import * as prompt from '../legacy/server/utils/prompt'; +import * as prompt from './utils/prompt'; describe('Kibana keystore', () => { describe('add', () => { diff --git a/src/cli_keystore/create.js b/src/cli_keystore/create.js index 8be1eb36882f1..55fe2c151dec0 100644 --- a/src/cli_keystore/create.js +++ b/src/cli_keystore/create.js @@ -18,7 +18,7 @@ */ import { Logger } from '../cli_plugin/lib/logger'; -import { confirm } from '../legacy/server/utils'; +import { confirm } from './utils'; export async function create(keystore, command, options) { const logger = new Logger(options); diff --git a/src/cli_keystore/create.test.js b/src/cli_keystore/create.test.js index f48b3775ddfff..cb85475eab1cb 100644 --- a/src/cli_keystore/create.test.js +++ b/src/cli_keystore/create.test.js @@ -41,7 +41,7 @@ import sinon from 'sinon'; import { Keystore } from '../legacy/server/keystore'; import { create } from './create'; import { Logger } from '../cli_plugin/lib/logger'; -import * as prompt from '../legacy/server/utils/prompt'; +import * as prompt from './utils/prompt'; describe('Kibana keystore', () => { describe('create', () => { diff --git a/src/legacy/server/utils/index.js b/src/cli_keystore/utils/index.js similarity index 100% rename from src/legacy/server/utils/index.js rename to src/cli_keystore/utils/index.js diff --git a/src/legacy/server/utils/prompt.js b/src/cli_keystore/utils/prompt.js similarity index 100% rename from src/legacy/server/utils/prompt.js rename to src/cli_keystore/utils/prompt.js diff --git a/src/legacy/server/utils/prompt.test.js b/src/cli_keystore/utils/prompt.test.js similarity index 100% rename from src/legacy/server/utils/prompt.test.js rename to src/cli_keystore/utils/prompt.test.js diff --git a/src/legacy/utils/index.js b/src/legacy/utils/index.js index 529b1ddfd8a4d..e2e2331b3aea6 100644 --- a/src/legacy/utils/index.js +++ b/src/legacy/utils/index.js @@ -17,8 +17,6 @@ * under the License. */ -export { BinderBase } from './binder'; -export { BinderFor } from './binder_for'; export { deepCloneWithBuffers } from './deep_clone_with_buffers'; export { unset } from './unset'; export { IS_KIBANA_DISTRIBUTABLE } from './artifact_type'; diff --git a/src/legacy/utils/path_contains.js b/src/legacy/utils/path_contains.js deleted file mode 100644 index 60d05c1099554..0000000000000 --- a/src/legacy/utils/path_contains.js +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { relative } from 'path'; - -export default function pathContains(root, child) { - return relative(child, root).slice(0, 2) !== '..'; -} From 0ca647286a5ccabfb76203b8cbcb1d13b05f105d Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Wed, 9 Sep 2020 09:00:27 -0700 Subject: [PATCH 05/25] [Metrics UI] Replace Snapshot API with Metrics API (#76253) - Remove server/lib/snapshot - Replace backend for /api/infra/snapshot with data from Metrics API - Fixing tests with updates to the snapshot node Co-authored-by: Elastic Machine --- x-pack/plugins/infra/common/http_api/index.ts | 1 + .../infra/common/http_api/metrics_api.ts | 6 +- .../infra/common/http_api/metrics_explorer.ts | 5 +- .../infra/common/http_api/snapshot_api.ts | 5 +- .../infra/common/inventory_models/types.ts | 5 + .../waffle/conditional_tooltip.test.tsx | 1 + .../lib/calculate_bounds_from_nodes.test.ts | 2 + .../inventory_view/lib/sort_nodes.test.ts | 2 + .../evaluate_condition.ts | 24 +- .../inventory_metric_threshold_executor.ts | 4 +- ...review_inventory_metric_threshold_alert.ts | 6 +- .../metric_threshold/lib/metric_query.ts | 4 +- .../plugins/infra/server/lib/infra_types.ts | 2 - .../create_aggregations.test.ts.snap | 1 - ...convert_histogram_buckets_to_timeseries.ts | 18 +- .../lib/metrics/lib/create_aggregations.ts | 2 +- .../plugins/infra/server/lib/metrics/types.ts | 36 ++- .../infra/server/lib/snapshot/constants.ts | 9 - .../server/lib/snapshot/query_helpers.ts | 106 -------- .../lib/snapshot/response_helpers.test.ts | 119 --------- .../server/lib/snapshot/response_helpers.ts | 208 --------------- .../infra/server/lib/snapshot/snapshot.ts | 238 ------------------ .../infra/server/lib/snapshot/types.ts | 15 -- .../infra/server/lib/sources/has_data.ts | 2 +- x-pack/plugins/infra/server/plugin.ts | 3 - .../infra/server/routes/alerting/preview.ts | 2 +- .../lib/find_interval_for_metrics.ts | 2 +- .../lib/get_dataset_for_field.ts | 2 +- .../infra/server/routes/snapshot/index.ts | 37 +-- .../lib/apply_metadata_to_last_path.ts | 65 +++++ ...alculate_index_pattern_based_on_metrics.ts | 22 ++ .../snapshot/lib/constants.ts} | 2 +- .../snapshot/lib/copy_missing_metrics.ts | 45 ++++ .../lib}/create_timerange_with_interval.ts | 18 +- .../snapshot/lib/get_metrics_aggregations.ts | 69 +++++ .../server/routes/snapshot/lib/get_nodes.ts | 34 +++ .../routes/snapshot/lib/query_all_data.ts | 33 +++ ...ransform_request_to_metrics_api_request.ts | 84 +++++++ ...snapshot_metrics_to_metrics_api_metrics.ts | 38 +++ .../lib/trasform_metrics_ui_response.ts | 87 +++++++ .../server/utils/calculate_metric_interval.ts | 2 +- .../apis/metrics_ui/snapshot.ts | 5 +- 42 files changed, 592 insertions(+), 779 deletions(-) delete mode 100644 x-pack/plugins/infra/server/lib/snapshot/constants.ts delete mode 100644 x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts delete mode 100644 x-pack/plugins/infra/server/lib/snapshot/response_helpers.test.ts delete mode 100644 x-pack/plugins/infra/server/lib/snapshot/response_helpers.ts delete mode 100644 x-pack/plugins/infra/server/lib/snapshot/snapshot.ts delete mode 100644 x-pack/plugins/infra/server/lib/snapshot/types.ts create mode 100644 x-pack/plugins/infra/server/routes/snapshot/lib/apply_metadata_to_last_path.ts create mode 100644 x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts rename x-pack/plugins/infra/server/{lib/snapshot/index.ts => routes/snapshot/lib/constants.ts} (85%) create mode 100644 x-pack/plugins/infra/server/routes/snapshot/lib/copy_missing_metrics.ts rename x-pack/plugins/infra/server/{lib/snapshot => routes/snapshot/lib}/create_timerange_with_interval.ts (80%) create mode 100644 x-pack/plugins/infra/server/routes/snapshot/lib/get_metrics_aggregations.ts create mode 100644 x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts create mode 100644 x-pack/plugins/infra/server/routes/snapshot/lib/query_all_data.ts create mode 100644 x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts create mode 100644 x-pack/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts create mode 100644 x-pack/plugins/infra/server/routes/snapshot/lib/trasform_metrics_ui_response.ts diff --git a/x-pack/plugins/infra/common/http_api/index.ts b/x-pack/plugins/infra/common/http_api/index.ts index 818009417fb1c..4c729d11ba8c1 100644 --- a/x-pack/plugins/infra/common/http_api/index.ts +++ b/x-pack/plugins/infra/common/http_api/index.ts @@ -10,3 +10,4 @@ export * from './log_entries'; export * from './metrics_explorer'; export * from './metrics_api'; export * from './log_alerts'; +export * from './snapshot_api'; diff --git a/x-pack/plugins/infra/common/http_api/metrics_api.ts b/x-pack/plugins/infra/common/http_api/metrics_api.ts index 7436566f039ca..41657fdce2153 100644 --- a/x-pack/plugins/infra/common/http_api/metrics_api.ts +++ b/x-pack/plugins/infra/common/http_api/metrics_api.ts @@ -33,7 +33,6 @@ export const MetricsAPIRequestRT = rt.intersection([ afterKey: rt.union([rt.null, afterKeyObjectRT]), limit: rt.union([rt.number, rt.null, rt.undefined]), filters: rt.array(rt.object), - forceInterval: rt.boolean, dropLastBucket: rt.boolean, alignDataToEnd: rt.boolean, }), @@ -59,7 +58,10 @@ export const MetricsAPIRowRT = rt.intersection([ rt.type({ timestamp: rt.number, }), - rt.record(rt.string, rt.union([rt.string, rt.number, rt.null, rt.undefined])), + rt.record( + rt.string, + rt.union([rt.string, rt.number, rt.null, rt.undefined, rt.array(rt.object)]) + ), ]); export const MetricsAPISeriesRT = rt.intersection([ diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts index c5776e0b0ced1..460b2bf9d802e 100644 --- a/x-pack/plugins/infra/common/http_api/metrics_explorer.ts +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts @@ -89,7 +89,10 @@ export const metricsExplorerRowRT = rt.intersection([ rt.type({ timestamp: rt.number, }), - rt.record(rt.string, rt.union([rt.string, rt.number, rt.null, rt.undefined])), + rt.record( + rt.string, + rt.union([rt.string, rt.number, rt.null, rt.undefined, rt.array(rt.object)]) + ), ]); export const metricsExplorerSeriesRT = rt.intersection([ diff --git a/x-pack/plugins/infra/common/http_api/snapshot_api.ts b/x-pack/plugins/infra/common/http_api/snapshot_api.ts index 11cb57238f917..e1b8dfa4770ba 100644 --- a/x-pack/plugins/infra/common/http_api/snapshot_api.ts +++ b/x-pack/plugins/infra/common/http_api/snapshot_api.ts @@ -6,7 +6,7 @@ import * as rt from 'io-ts'; import { SnapshotMetricTypeRT, ItemTypeRT } from '../inventory_models/types'; -import { metricsExplorerSeriesRT } from './metrics_explorer'; +import { MetricsAPISeriesRT } from './metrics_api'; export const SnapshotNodePathRT = rt.intersection([ rt.type({ @@ -22,7 +22,7 @@ const SnapshotNodeMetricOptionalRT = rt.partial({ value: rt.union([rt.number, rt.null]), avg: rt.union([rt.number, rt.null]), max: rt.union([rt.number, rt.null]), - timeseries: metricsExplorerSeriesRT, + timeseries: MetricsAPISeriesRT, }); const SnapshotNodeMetricRequiredRT = rt.type({ @@ -36,6 +36,7 @@ export const SnapshotNodeMetricRT = rt.intersection([ export const SnapshotNodeRT = rt.type({ metrics: rt.array(SnapshotNodeMetricRT), path: rt.array(SnapshotNodePathRT), + name: rt.string, }); export const SnapshotNodeResponseRT = rt.type({ diff --git a/x-pack/plugins/infra/common/inventory_models/types.ts b/x-pack/plugins/infra/common/inventory_models/types.ts index 570220bbc7aa5..851646ef1fa12 100644 --- a/x-pack/plugins/infra/common/inventory_models/types.ts +++ b/x-pack/plugins/infra/common/inventory_models/types.ts @@ -281,6 +281,10 @@ export const ESSumBucketAggRT = rt.type({ }), }); +export const ESTopHitsAggRT = rt.type({ + top_hits: rt.object, +}); + interface SnapshotTermsWithAggregation { terms: { field: string }; aggregations: MetricsUIAggregation; @@ -304,6 +308,7 @@ export const ESAggregationRT = rt.union([ ESSumBucketAggRT, ESTermsWithAggregationRT, ESCaridnalityAggRT, + ESTopHitsAggRT, ]); export const MetricsUIAggregationRT = rt.record(rt.string, ESAggregationRT); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx index d2c30a4f38ee9..e01ca3ab6e844 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/conditional_tooltip.test.tsx @@ -88,6 +88,7 @@ describe('ConditionalToolTip', () => { mockedUseSnapshot.mockReturnValue({ nodes: [ { + name: 'host-01', path: [{ label: 'host-01', value: 'host-01', ip: '192.168.1.10' }], metrics: [ { name: 'cpu', value: 0.1, avg: 0.4, max: 0.7 }, diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts index fbb6aa933219a..49f4b56532936 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts @@ -7,6 +7,7 @@ import { calculateBoundsFromNodes } from './calculate_bounds_from_nodes'; import { SnapshotNode } from '../../../../../common/http_api/snapshot_api'; const nodes: SnapshotNode[] = [ { + name: 'host-01', path: [{ value: 'host-01', label: 'host-01' }], metrics: [ { @@ -18,6 +19,7 @@ const nodes: SnapshotNode[] = [ ], }, { + name: 'host-02', path: [{ value: 'host-02', label: 'host-02' }], metrics: [ { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/sort_nodes.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/sort_nodes.test.ts index 2a9f8b911c124..f7d9f029f00df 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/sort_nodes.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/sort_nodes.test.ts @@ -9,6 +9,7 @@ import { SnapshotNode } from '../../../../../common/http_api/snapshot_api'; const nodes: SnapshotNode[] = [ { + name: 'host-01', path: [{ value: 'host-01', label: 'host-01' }], metrics: [ { @@ -20,6 +21,7 @@ const nodes: SnapshotNode[] = [ ], }, { + name: 'host-02', path: [{ value: 'host-02', label: 'host-02' }], metrics: [ { diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 2f3593a11f664..d6592719d0723 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -16,12 +16,11 @@ import { } from '../../adapters/framework/adapter_types'; import { Comparator, InventoryMetricConditions } from './types'; import { AlertServices } from '../../../../../alerts/server'; -import { InfraSnapshot } from '../../snapshot'; -import { parseFilterQuery } from '../../../utils/serialized_query'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; -import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; -import { InfraSourceConfiguration } from '../../sources'; +import { InfraTimerangeInput, SnapshotRequest } from '../../../../common/http_api/snapshot_api'; +import { InfraSource } from '../../sources'; import { UNGROUPED_FACTORY_KEY } from '../common/utils'; +import { getNodes } from '../../../routes/snapshot/lib/get_nodes'; type ConditionResult = InventoryMetricConditions & { shouldFire: boolean[]; @@ -33,7 +32,7 @@ type ConditionResult = InventoryMetricConditions & { export const evaluateCondition = async ( condition: InventoryMetricConditions, nodeType: InventoryItemType, - sourceConfiguration: InfraSourceConfiguration, + source: InfraSource, callCluster: AlertServices['callCluster'], filterQuery?: string, lookbackSize?: number @@ -55,7 +54,7 @@ export const evaluateCondition = async ( nodeType, metric, timerange, - sourceConfiguration, + source, filterQuery, customMetric ); @@ -94,12 +93,11 @@ const getData = async ( nodeType: InventoryItemType, metric: SnapshotMetricType, timerange: InfraTimerangeInput, - sourceConfiguration: InfraSourceConfiguration, + source: InfraSource, filterQuery?: string, customMetric?: SnapshotCustomMetricInput ) => { - const snapshot = new InfraSnapshot(); - const esClient = ( + const client = ( options: CallWithRequestParams ): Promise> => callCluster('search', options); @@ -107,17 +105,17 @@ const getData = async ( metric === 'custom' ? (customMetric as SnapshotCustomMetricInput) : { type: metric }, ]; - const options = { - filterQuery: parseFilterQuery(filterQuery), + const snapshotRequest: SnapshotRequest = { + filterQuery, nodeType, groupBy: [], - sourceConfiguration, + sourceId: 'default', metrics, timerange, includeTimeseries: Boolean(timerange.lookbackSize), }; try { - const { nodes } = await snapshot.getNodes(esClient, options); + const { nodes } = await getNodes(client, snapshotRequest, source); if (!nodes.length) return { [UNGROUPED_FACTORY_KEY]: null }; // No Data state diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index bdac9dcd1dee8..99904f15b4606 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -50,9 +50,7 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = ); const results = await Promise.all( - criteria.map((c) => - evaluateCondition(c, nodeType, source.configuration, services.callCluster, filterQuery) - ) + criteria.map((c) => evaluateCondition(c, nodeType, source, services.callCluster, filterQuery)) ); const inventoryItems = Object.keys(first(results)!); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts index 755c395818f5a..2ab015b6b37a2 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert.ts @@ -26,7 +26,7 @@ interface InventoryMetricThresholdParams { interface PreviewInventoryMetricThresholdAlertParams { callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; params: InventoryMetricThresholdParams; - config: InfraSource['configuration']; + source: InfraSource; lookback: Unit; alertInterval: string; } @@ -34,7 +34,7 @@ interface PreviewInventoryMetricThresholdAlertParams { export const previewInventoryMetricThresholdAlert = async ({ callCluster, params, - config, + source, lookback, alertInterval, }: PreviewInventoryMetricThresholdAlertParams) => { @@ -55,7 +55,7 @@ export const previewInventoryMetricThresholdAlert = async ({ try { const results = await Promise.all( criteria.map((c) => - evaluateCondition(c, nodeType, config, callCluster, filterQuery, lookbackSize) + evaluateCondition(c, nodeType, source, callCluster, filterQuery, lookbackSize) ) ); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts index 078ca46d42e60..8696081043ff7 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/metric_query.ts @@ -8,8 +8,8 @@ import { networkTraffic } from '../../../../../common/inventory_models/shared/me import { MetricExpressionParams, Aggregators } from '../types'; import { getIntervalInSeconds } from '../../../../utils/get_interval_in_seconds'; import { roundTimestamp } from '../../../../utils/round_timestamp'; -import { getDateHistogramOffset } from '../../../snapshot/query_helpers'; import { createPercentileAggregation } from './create_percentile_aggregation'; +import { calculateDateHistogramOffset } from '../../../metrics/lib/calculate_date_histogram_offset'; const MINIMUM_BUCKETS = 5; @@ -46,7 +46,7 @@ export const getElasticsearchMetricQuery = ( timeUnit ); - const offset = getDateHistogramOffset(from, interval); + const offset = calculateDateHistogramOffset({ from, to, interval, field: timefield }); const aggregations = aggType === Aggregators.COUNT diff --git a/x-pack/plugins/infra/server/lib/infra_types.ts b/x-pack/plugins/infra/server/lib/infra_types.ts index 9896ad6ac1cd1..084ece52302b0 100644 --- a/x-pack/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/plugins/infra/server/lib/infra_types.ts @@ -8,7 +8,6 @@ import { InfraSourceConfiguration } from '../../common/graphql/types'; import { InfraFieldsDomain } from './domains/fields_domain'; import { InfraLogEntriesDomain } from './domains/log_entries_domain'; import { InfraMetricsDomain } from './domains/metrics_domain'; -import { InfraSnapshot } from './snapshot'; import { InfraSources } from './sources'; import { InfraSourceStatus } from './source_status'; import { InfraConfig } from '../plugin'; @@ -30,7 +29,6 @@ export interface InfraDomainLibs { export interface InfraBackendLibs extends InfraDomainLibs { configuration: InfraConfig; framework: KibanaFramework; - snapshot: InfraSnapshot; sources: InfraSources; sourceStatus: InfraSourceStatus; } diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap b/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap index d2d90914eced5..2cbbc623aed38 100644 --- a/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap +++ b/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap @@ -53,7 +53,6 @@ Object { "groupBy0": Object { "terms": Object { "field": "host.name", - "order": "asc", }, }, }, diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts b/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts index 95e6ece215133..90e584368e9ad 100644 --- a/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts +++ b/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts @@ -5,6 +5,7 @@ */ import { get, values, first } from 'lodash'; +import * as rt from 'io-ts'; import { MetricsAPIRequest, MetricsAPISeries, @@ -13,15 +14,20 @@ import { } from '../../../../common/http_api/metrics_api'; import { HistogramBucket, - MetricValueType, BasicMetricValueRT, NormalizedMetricValueRT, PercentilesTypeRT, PercentilesKeyedTypeRT, + TopHitsTypeRT, + MetricValueTypeRT, } from '../types'; + const BASE_COLUMNS = [{ name: 'timestamp', type: 'date' }] as MetricsAPIColumn[]; -const getValue = (valueObject: string | number | MetricValueType) => { +const ValueObjectTypeRT = rt.union([rt.string, rt.number, MetricValueTypeRT]); +type ValueObjectType = rt.TypeOf; + +const getValue = (valueObject: ValueObjectType) => { if (NormalizedMetricValueRT.is(valueObject)) { return valueObject.normalized_value || valueObject.value; } @@ -50,6 +56,10 @@ const getValue = (valueObject: string | number | MetricValueType) => { return valueObject.value; } + if (TopHitsTypeRT.is(valueObject)) { + return valueObject.hits.hits.map((hit) => hit._source); + } + return null; }; @@ -61,8 +71,8 @@ const convertBucketsToRows = ( const ids = options.metrics.map((metric) => metric.id); const metrics = ids.reduce((acc, id) => { const valueObject = get(bucket, [id]); - return { ...acc, [id]: getValue(valueObject) }; - }, {} as Record); + return { ...acc, [id]: ValueObjectTypeRT.is(valueObject) ? getValue(valueObject) : null }; + }, {} as Record); return { timestamp: bucket.key as number, ...metrics }; }); }; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts b/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts index 991e5febfc634..63fdbb3d2b30f 100644 --- a/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts +++ b/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts @@ -33,7 +33,7 @@ export const createAggregations = (options: MetricsAPIRequest) => { composite: { size: limit, sources: options.groupBy.map((field, index) => ({ - [`groupBy${index}`]: { terms: { field, order: 'asc' } }, + [`groupBy${index}`]: { terms: { field } }, })), }, aggs: histogramAggregation, diff --git a/x-pack/plugins/infra/server/lib/metrics/types.ts b/x-pack/plugins/infra/server/lib/metrics/types.ts index d1866470e0cf9..8746614b559d6 100644 --- a/x-pack/plugins/infra/server/lib/metrics/types.ts +++ b/x-pack/plugins/infra/server/lib/metrics/types.ts @@ -25,17 +25,51 @@ export const PercentilesKeyedTypeRT = rt.type({ values: rt.array(rt.type({ key: rt.string, value: NumberOrNullRT })), }); +export const TopHitsTypeRT = rt.type({ + hits: rt.type({ + total: rt.type({ + value: rt.number, + relation: rt.string, + }), + hits: rt.array( + rt.intersection([ + rt.type({ + _index: rt.string, + _id: rt.string, + _score: NumberOrNullRT, + _source: rt.object, + }), + rt.partial({ + sort: rt.array(rt.union([rt.string, rt.number])), + max_score: NumberOrNullRT, + }), + ]) + ), + }), +}); + export const MetricValueTypeRT = rt.union([ BasicMetricValueRT, NormalizedMetricValueRT, PercentilesTypeRT, PercentilesKeyedTypeRT, + TopHitsTypeRT, ]); export type MetricValueType = rt.TypeOf; +export const TermsWithMetrics = rt.intersection([ + rt.type({ + buckets: rt.array(rt.record(rt.string, rt.union([rt.number, rt.string, MetricValueTypeRT]))), + }), + rt.partial({ + sum_other_doc_count: rt.number, + doc_count_error_upper_bound: rt.number, + }), +]); + export const HistogramBucketRT = rt.record( rt.string, - rt.union([rt.number, rt.string, MetricValueTypeRT]) + rt.union([rt.number, rt.string, MetricValueTypeRT, TermsWithMetrics]) ); export const HistogramResponseRT = rt.type({ diff --git a/x-pack/plugins/infra/server/lib/snapshot/constants.ts b/x-pack/plugins/infra/server/lib/snapshot/constants.ts deleted file mode 100644 index 0420878dbcf50..0000000000000 --- a/x-pack/plugins/infra/server/lib/snapshot/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// TODO: Make SNAPSHOT_COMPOSITE_REQUEST_SIZE configurable from kibana.yml - -export const SNAPSHOT_COMPOSITE_REQUEST_SIZE = 75; diff --git a/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts b/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts deleted file mode 100644 index ca63043ba868e..0000000000000 --- a/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { findInventoryModel, findInventoryFields } from '../../../common/inventory_models/index'; -import { InfraSnapshotRequestOptions } from './types'; -import { getIntervalInSeconds } from '../../utils/get_interval_in_seconds'; -import { - MetricsUIAggregation, - MetricsUIAggregationRT, - InventoryItemType, -} from '../../../common/inventory_models/types'; -import { - SnapshotMetricInput, - SnapshotCustomMetricInputRT, -} from '../../../common/http_api/snapshot_api'; -import { networkTraffic } from '../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; - -interface GroupBySource { - [id: string]: { - terms: { - field: string | null | undefined; - missing_bucket?: boolean; - }; - }; -} - -export const getFieldByNodeType = (options: InfraSnapshotRequestOptions) => { - const inventoryFields = findInventoryFields(options.nodeType, options.sourceConfiguration.fields); - return inventoryFields.id; -}; - -export const getGroupedNodesSources = (options: InfraSnapshotRequestOptions) => { - const fields = findInventoryFields(options.nodeType, options.sourceConfiguration.fields); - const sources: GroupBySource[] = options.groupBy.map((gb) => { - return { [`${gb.field}`]: { terms: { field: gb.field } } }; - }); - sources.push({ - id: { - terms: { field: fields.id }, - }, - }); - sources.push({ - name: { terms: { field: fields.name, missing_bucket: true } }, - }); - return sources; -}; - -export const getMetricsSources = (options: InfraSnapshotRequestOptions) => { - const fields = findInventoryFields(options.nodeType, options.sourceConfiguration.fields); - return [{ id: { terms: { field: fields.id } } }]; -}; - -export const metricToAggregation = ( - nodeType: InventoryItemType, - metric: SnapshotMetricInput, - index: number -) => { - const inventoryModel = findInventoryModel(nodeType); - if (SnapshotCustomMetricInputRT.is(metric)) { - if (metric.aggregation === 'rate') { - return networkTraffic(`custom_${index}`, metric.field); - } - return { - [`custom_${index}`]: { - [metric.aggregation]: { - field: metric.field, - }, - }, - }; - } - return inventoryModel.metrics.snapshot?.[metric.type]; -}; - -export const getMetricsAggregations = ( - options: InfraSnapshotRequestOptions -): MetricsUIAggregation => { - const { metrics } = options; - return metrics.reduce((aggs, metric, index) => { - const aggregation = metricToAggregation(options.nodeType, metric, index); - if (!MetricsUIAggregationRT.is(aggregation)) { - throw new Error( - i18n.translate('xpack.infra.snapshot.missingSnapshotMetricError', { - defaultMessage: 'The aggregation for {metric} for {nodeType} is not available.', - values: { - nodeType: options.nodeType, - metric: metric.type, - }, - }) - ); - } - return { ...aggs, ...aggregation }; - }, {}); -}; - -export const getDateHistogramOffset = (from: number, interval: string): string => { - const fromInSeconds = Math.floor(from / 1000); - const bucketSizeInSeconds = getIntervalInSeconds(interval); - - // negative offset to align buckets with full intervals (e.g. minutes) - const offset = (fromInSeconds % bucketSizeInSeconds) - bucketSizeInSeconds; - return `${offset}s`; -}; diff --git a/x-pack/plugins/infra/server/lib/snapshot/response_helpers.test.ts b/x-pack/plugins/infra/server/lib/snapshot/response_helpers.test.ts deleted file mode 100644 index 74840afc157d2..0000000000000 --- a/x-pack/plugins/infra/server/lib/snapshot/response_helpers.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - isIPv4, - getIPFromBucket, - InfraSnapshotNodeGroupByBucket, - getMetricValueFromBucket, - InfraSnapshotMetricsBucket, -} from './response_helpers'; - -describe('InfraOps ResponseHelpers', () => { - describe('isIPv4', () => { - it('should return true for IPv4', () => { - expect(isIPv4('192.168.2.4')).toBe(true); - }); - it('should return false for anything else', () => { - expect(isIPv4('0:0:0:0:0:0:0:1')).toBe(false); - }); - }); - - describe('getIPFromBucket', () => { - it('should return IPv4 address', () => { - const bucket: InfraSnapshotNodeGroupByBucket = { - key: { - id: 'example-01', - name: 'example-01', - }, - ip: { - hits: { - total: { value: 1 }, - hits: [ - { - _index: 'metricbeat-2019-01-01', - _type: '_doc', - _id: '29392939', - _score: null, - sort: [], - _source: { - host: { - ip: ['2001:db8:85a3::8a2e:370:7334', '192.168.1.4'], - }, - }, - }, - ], - }, - }, - }; - expect(getIPFromBucket('host', bucket)).toBe('192.168.1.4'); - }); - it('should NOT return ipv6 address', () => { - const bucket: InfraSnapshotNodeGroupByBucket = { - key: { - id: 'example-01', - name: 'example-01', - }, - ip: { - hits: { - total: { value: 1 }, - hits: [ - { - _index: 'metricbeat-2019-01-01', - _type: '_doc', - _id: '29392939', - _score: null, - sort: [], - _source: { - host: { - ip: ['2001:db8:85a3::8a2e:370:7334'], - }, - }, - }, - ], - }, - }, - }; - expect(getIPFromBucket('host', bucket)).toBe(null); - }); - }); - - describe('getMetricValueFromBucket', () => { - it('should return the value of a bucket with data', () => { - expect(getMetricValueFromBucket('custom', testBucket, 1)).toBe(0.5); - }); - it('should return the normalized value of a bucket with data', () => { - expect(getMetricValueFromBucket('cpu', testNormalizedBucket, 1)).toBe(50); - }); - it('should return null for a bucket with no data', () => { - expect(getMetricValueFromBucket('custom', testEmptyBucket, 1)).toBe(null); - }); - }); -}); - -// Hack to get around TypeScript -const buckets = [ - { - key: 'a', - doc_count: 1, - custom_1: { - value: 0.5, - }, - }, - { - key: 'b', - doc_count: 1, - cpu: { - value: 0.5, - normalized_value: 50, - }, - }, - { - key: 'c', - doc_count: 0, - }, -] as InfraSnapshotMetricsBucket[]; -const [testBucket, testNormalizedBucket, testEmptyBucket] = buckets; diff --git a/x-pack/plugins/infra/server/lib/snapshot/response_helpers.ts b/x-pack/plugins/infra/server/lib/snapshot/response_helpers.ts deleted file mode 100644 index 2652e362b7eff..0000000000000 --- a/x-pack/plugins/infra/server/lib/snapshot/response_helpers.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { isNumber, last, max, sum, get } from 'lodash'; -import moment from 'moment'; - -import { MetricsExplorerSeries } from '../../../common/http_api/metrics_explorer'; -import { getIntervalInSeconds } from '../../utils/get_interval_in_seconds'; -import { InfraSnapshotRequestOptions } from './types'; -import { findInventoryModel } from '../../../common/inventory_models'; -import { InventoryItemType, SnapshotMetricType } from '../../../common/inventory_models/types'; -import { SnapshotNodeMetric, SnapshotNodePath } from '../../../common/http_api/snapshot_api'; - -export interface InfraSnapshotNodeMetricsBucket { - key: { id: string }; - histogram: { - buckets: InfraSnapshotMetricsBucket[]; - }; -} - -// Jumping through TypeScript hoops here: -// We need an interface that has the known members 'key' and 'doc_count' and also -// an unknown number of members with unknown names but known format, containing the -// metrics. -// This union type is the only way I found to express this that TypeScript accepts. -export interface InfraSnapshotBucketWithKey { - key: string | number; - doc_count: number; -} - -export interface InfraSnapshotBucketWithValues { - [name: string]: { value: number; normalized_value?: number }; -} - -export type InfraSnapshotMetricsBucket = InfraSnapshotBucketWithKey & InfraSnapshotBucketWithValues; - -interface InfraSnapshotIpHit { - _index: string; - _type: string; - _id: string; - _score: number | null; - _source: { - host: { - ip: string[] | string; - }; - }; - sort: number[]; -} - -export interface InfraSnapshotNodeGroupByBucket { - key: { - id: string; - name: string; - [groupByField: string]: string; - }; - ip: { - hits: { - total: { value: number }; - hits: InfraSnapshotIpHit[]; - }; - }; -} - -export const isIPv4 = (subject: string) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(subject); - -export const getIPFromBucket = ( - nodeType: InventoryItemType, - bucket: InfraSnapshotNodeGroupByBucket -): string | null => { - const inventoryModel = findInventoryModel(nodeType); - if (!inventoryModel.fields.ip) { - return null; - } - const ip = get(bucket, `ip.hits.hits[0]._source.${inventoryModel.fields.ip}`, null) as - | string[] - | null; - if (Array.isArray(ip)) { - return ip.find(isIPv4) || null; - } else if (typeof ip === 'string') { - return ip; - } - - return null; -}; - -export const getNodePath = ( - groupBucket: InfraSnapshotNodeGroupByBucket, - options: InfraSnapshotRequestOptions -): SnapshotNodePath[] => { - const node = groupBucket.key; - const path = options.groupBy.map((gb) => { - return { value: node[`${gb.field}`], label: node[`${gb.field}`] } as SnapshotNodePath; - }); - const ip = getIPFromBucket(options.nodeType, groupBucket); - path.push({ value: node.id, label: node.name || node.id, ip }); - return path; -}; - -interface NodeMetricsForLookup { - [nodeId: string]: InfraSnapshotMetricsBucket[]; -} - -export const getNodeMetricsForLookup = ( - metrics: InfraSnapshotNodeMetricsBucket[] -): NodeMetricsForLookup => { - return metrics.reduce((acc: NodeMetricsForLookup, metric) => { - acc[`${metric.key.id}`] = metric.histogram.buckets; - return acc; - }, {}); -}; - -// In the returned object, -// value contains the value from the last bucket spanning a full interval -// max and avg are calculated from all buckets returned for the timerange -export const getNodeMetrics = ( - nodeBuckets: InfraSnapshotMetricsBucket[], - options: InfraSnapshotRequestOptions -): SnapshotNodeMetric[] => { - if (!nodeBuckets) { - return options.metrics.map((metric) => ({ - name: metric.type, - value: null, - max: null, - avg: null, - })); - } - const lastBucket = findLastFullBucket(nodeBuckets, options); - if (!lastBucket) return []; - return options.metrics.map((metric, index) => { - const metricResult: SnapshotNodeMetric = { - name: metric.type, - value: getMetricValueFromBucket(metric.type, lastBucket, index), - max: calculateMax(nodeBuckets, metric.type, index), - avg: calculateAvg(nodeBuckets, metric.type, index), - }; - if (options.includeTimeseries) { - metricResult.timeseries = getTimeseriesData(nodeBuckets, metric.type, index); - } - return metricResult; - }); -}; - -const findLastFullBucket = ( - buckets: InfraSnapshotMetricsBucket[], - options: InfraSnapshotRequestOptions -) => { - const to = moment.utc(options.timerange.to); - const bucketSize = getIntervalInSeconds(options.timerange.interval); - return buckets.reduce((current, item) => { - const itemKey = isNumber(item.key) ? item.key : parseInt(item.key, 10); - const date = moment.utc(itemKey + bucketSize * 1000); - if (!date.isAfter(to) && item.doc_count > 0) { - return item; - } - return current; - }, last(buckets)); -}; - -export const getMetricValueFromBucket = ( - type: SnapshotMetricType, - bucket: InfraSnapshotMetricsBucket, - index: number -) => { - const key = type === 'custom' ? `custom_${index}` : type; - const metric = bucket[key]; - const value = metric && (metric.normalized_value || metric.value); - return isFinite(value) ? value : null; -}; - -function calculateMax( - buckets: InfraSnapshotMetricsBucket[], - type: SnapshotMetricType, - index: number -) { - return max(buckets.map((bucket) => getMetricValueFromBucket(type, bucket, index))) || 0; -} - -function calculateAvg( - buckets: InfraSnapshotMetricsBucket[], - type: SnapshotMetricType, - index: number -) { - return ( - sum(buckets.map((bucket) => getMetricValueFromBucket(type, bucket, index))) / buckets.length || - 0 - ); -} - -function getTimeseriesData( - buckets: InfraSnapshotMetricsBucket[], - type: SnapshotMetricType, - index: number -): MetricsExplorerSeries { - return { - id: type, - columns: [ - { name: 'timestamp', type: 'date' }, - { name: 'metric_0', type: 'number' }, - ], - rows: buckets.map((bucket) => ({ - timestamp: bucket.key as number, - metric_0: getMetricValueFromBucket(type, bucket, index), - })), - }; -} diff --git a/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts b/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts deleted file mode 100644 index 33d8e738a717e..0000000000000 --- a/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { InfraDatabaseSearchResponse, CallWithRequestParams } from '../adapters/framework'; - -import { JsonObject } from '../../../common/typed_json'; -import { SNAPSHOT_COMPOSITE_REQUEST_SIZE } from './constants'; -import { - getGroupedNodesSources, - getMetricsAggregations, - getMetricsSources, - getDateHistogramOffset, -} from './query_helpers'; -import { - getNodeMetrics, - getNodeMetricsForLookup, - getNodePath, - InfraSnapshotNodeGroupByBucket, - InfraSnapshotNodeMetricsBucket, -} from './response_helpers'; -import { getAllCompositeData } from '../../utils/get_all_composite_data'; -import { createAfterKeyHandler } from '../../utils/create_afterkey_handler'; -import { findInventoryModel } from '../../../common/inventory_models'; -import { InfraSnapshotRequestOptions } from './types'; -import { createTimeRangeWithInterval } from './create_timerange_with_interval'; -import { SnapshotNode } from '../../../common/http_api/snapshot_api'; - -type NamedSnapshotNode = SnapshotNode & { name: string }; - -export type ESSearchClient = ( - options: CallWithRequestParams -) => Promise>; -export class InfraSnapshot { - public async getNodes( - client: ESSearchClient, - options: InfraSnapshotRequestOptions - ): Promise<{ nodes: NamedSnapshotNode[]; interval: string }> { - // Both requestGroupedNodes and requestNodeMetrics may send several requests to elasticsearch - // in order to page through the results of their respective composite aggregations. - // Both chains of requests are supposed to run in parallel, and their results be merged - // when they have both been completed. - const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(client, options); - const optionsWithTimerange = { ...options, timerange: timeRangeWithIntervalApplied }; - - const groupedNodesPromise = requestGroupedNodes(client, optionsWithTimerange); - const nodeMetricsPromise = requestNodeMetrics(client, optionsWithTimerange); - const [groupedNodeBuckets, nodeMetricBuckets] = await Promise.all([ - groupedNodesPromise, - nodeMetricsPromise, - ]); - return { - nodes: mergeNodeBuckets(groupedNodeBuckets, nodeMetricBuckets, options), - interval: timeRangeWithIntervalApplied.interval, - }; - } -} - -const bucketSelector = ( - response: InfraDatabaseSearchResponse<{}, InfraSnapshotAggregationResponse> -) => (response.aggregations && response.aggregations.nodes.buckets) || []; - -const handleAfterKey = createAfterKeyHandler( - 'body.aggregations.nodes.composite.after', - (input) => input?.aggregations?.nodes?.after_key -); - -const callClusterFactory = (search: ESSearchClient) => (opts: any) => - search<{}, InfraSnapshotAggregationResponse>(opts); - -const requestGroupedNodes = async ( - client: ESSearchClient, - options: InfraSnapshotRequestOptions -): Promise => { - const inventoryModel = findInventoryModel(options.nodeType); - const query = { - allowNoIndices: true, - index: `${options.sourceConfiguration.logAlias},${options.sourceConfiguration.metricAlias}`, - ignoreUnavailable: true, - body: { - query: { - bool: { - filter: buildFilters(options), - }, - }, - size: 0, - aggregations: { - nodes: { - composite: { - size: options.overrideCompositeSize || SNAPSHOT_COMPOSITE_REQUEST_SIZE, - sources: getGroupedNodesSources(options), - }, - aggs: { - ip: { - top_hits: { - sort: [{ [options.sourceConfiguration.fields.timestamp]: { order: 'desc' } }], - _source: { - includes: inventoryModel.fields.ip ? [inventoryModel.fields.ip] : [], - }, - size: 1, - }, - }, - }, - }, - }, - }, - }; - return getAllCompositeData( - callClusterFactory(client), - query, - bucketSelector, - handleAfterKey - ); -}; - -const calculateIndexPatterBasedOnMetrics = (options: InfraSnapshotRequestOptions) => { - const { metrics } = options; - if (metrics.every((m) => m.type === 'logRate')) { - return options.sourceConfiguration.logAlias; - } - if (metrics.some((m) => m.type === 'logRate')) { - return `${options.sourceConfiguration.logAlias},${options.sourceConfiguration.metricAlias}`; - } - return options.sourceConfiguration.metricAlias; -}; - -const requestNodeMetrics = async ( - client: ESSearchClient, - options: InfraSnapshotRequestOptions -): Promise => { - const index = calculateIndexPatterBasedOnMetrics(options); - const query = { - allowNoIndices: true, - index, - ignoreUnavailable: true, - body: { - query: { - bool: { - filter: buildFilters(options, false), - }, - }, - size: 0, - aggregations: { - nodes: { - composite: { - size: options.overrideCompositeSize || SNAPSHOT_COMPOSITE_REQUEST_SIZE, - sources: getMetricsSources(options), - }, - aggregations: { - histogram: { - date_histogram: { - field: options.sourceConfiguration.fields.timestamp, - interval: options.timerange.interval || '1m', - offset: getDateHistogramOffset(options.timerange.from, options.timerange.interval), - extended_bounds: { - min: options.timerange.from, - max: options.timerange.to, - }, - }, - aggregations: getMetricsAggregations(options), - }, - }, - }, - }, - }, - }; - return getAllCompositeData( - callClusterFactory(client), - query, - bucketSelector, - handleAfterKey - ); -}; - -// buckets can be InfraSnapshotNodeGroupByBucket[] or InfraSnapshotNodeMetricsBucket[] -// but typing this in a way that makes TypeScript happy is unreadable (if possible at all) -interface InfraSnapshotAggregationResponse { - nodes: { - buckets: any[]; - after_key: { [id: string]: string }; - }; -} - -const mergeNodeBuckets = ( - nodeGroupByBuckets: InfraSnapshotNodeGroupByBucket[], - nodeMetricsBuckets: InfraSnapshotNodeMetricsBucket[], - options: InfraSnapshotRequestOptions -): NamedSnapshotNode[] => { - const nodeMetricsForLookup = getNodeMetricsForLookup(nodeMetricsBuckets); - - return nodeGroupByBuckets.map((node) => { - return { - name: node.key.name || node.key.id, // For type safety; name can be derived from getNodePath but not in a TS-friendly way - path: getNodePath(node, options), - metrics: getNodeMetrics(nodeMetricsForLookup[node.key.id], options), - }; - }); -}; - -const createQueryFilterClauses = (filterQuery: JsonObject | undefined) => - filterQuery ? [filterQuery] : []; - -const buildFilters = (options: InfraSnapshotRequestOptions, withQuery = true) => { - let filters: any = [ - { - range: { - [options.sourceConfiguration.fields.timestamp]: { - gte: options.timerange.from, - lte: options.timerange.to, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (withQuery) { - filters = [...createQueryFilterClauses(options.filterQuery), ...filters]; - } - - if (options.accountId) { - filters.push({ - term: { - 'cloud.account.id': options.accountId, - }, - }); - } - - if (options.region) { - filters.push({ - term: { - 'cloud.region': options.region, - }, - }); - } - - return filters; -}; diff --git a/x-pack/plugins/infra/server/lib/snapshot/types.ts b/x-pack/plugins/infra/server/lib/snapshot/types.ts deleted file mode 100644 index 7e17cb91c6a59..0000000000000 --- a/x-pack/plugins/infra/server/lib/snapshot/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { JsonObject } from '../../../common/typed_json'; -import { InfraSourceConfiguration } from '../../../common/graphql/types'; -import { SnapshotRequest } from '../../../common/http_api/snapshot_api'; - -export interface InfraSnapshotRequestOptions - extends Omit { - sourceConfiguration: InfraSourceConfiguration; - filterQuery: JsonObject | undefined; -} diff --git a/x-pack/plugins/infra/server/lib/sources/has_data.ts b/x-pack/plugins/infra/server/lib/sources/has_data.ts index 79b1375059dcb..53297640e541d 100644 --- a/x-pack/plugins/infra/server/lib/sources/has_data.ts +++ b/x-pack/plugins/infra/server/lib/sources/has_data.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ESSearchClient } from '../snapshot'; +import { ESSearchClient } from '../metrics/types'; export const hasData = async (index: string, client: ESSearchClient) => { const params = { diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 51f91d7189db7..90b73b9a7585a 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -19,7 +19,6 @@ import { InfraElasticsearchSourceStatusAdapter } from './lib/adapters/source_sta import { InfraFieldsDomain } from './lib/domains/fields_domain'; import { InfraLogEntriesDomain } from './lib/domains/log_entries_domain'; import { InfraMetricsDomain } from './lib/domains/metrics_domain'; -import { InfraSnapshot } from './lib/snapshot'; import { InfraSourceStatus } from './lib/source_status'; import { InfraSources } from './lib/sources'; import { InfraServerPluginDeps } from './lib/adapters/framework'; @@ -105,7 +104,6 @@ export class InfraServerPlugin { sources, } ); - const snapshot = new InfraSnapshot(); // register saved object types core.savedObjects.registerType(infraSourceConfigurationSavedObjectType); @@ -129,7 +127,6 @@ export class InfraServerPlugin { this.libs = { configuration: this.config, framework, - snapshot, sources, sourceStatus, ...domainLibs, diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index 5594323d706de..40d09dadfe050 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -82,7 +82,7 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) callCluster, params: { criteria, filterQuery, nodeType }, lookback, - config: source.configuration, + source, alertInterval, }); diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts index 876bbb4199441..8ab0f4a44c85d 100644 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts @@ -7,9 +7,9 @@ import { uniq } from 'lodash'; import LRU from 'lru-cache'; import { MetricsExplorerRequestBody } from '../../../../common/http_api'; -import { ESSearchClient } from '../../../lib/snapshot'; import { getDatasetForField } from './get_dataset_for_field'; import { calculateMetricInterval } from '../../../utils/calculate_metric_interval'; +import { ESSearchClient } from '../../../lib/metrics/types'; const cache = new LRU({ max: 100, diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts index 94e91d32b14bb..85bb5b106c87c 100644 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ESSearchClient } from '../../../lib/snapshot'; +import { ESSearchClient } from '../../../lib/metrics/types'; interface EventDatasetHit { _source: { diff --git a/x-pack/plugins/infra/server/routes/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/index.ts index 00bc1e74ea871..3f09ae89bc97e 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/index.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/index.ts @@ -10,10 +10,10 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { InfraBackendLibs } from '../../lib/infra_types'; import { UsageCollector } from '../../usage/usage_collector'; -import { parseFilterQuery } from '../../utils/serialized_query'; import { SnapshotRequestRT, SnapshotNodeResponseRT } from '../../../common/http_api/snapshot_api'; import { throwErrors } from '../../../common/runtime_types'; import { createSearchClient } from '../../lib/create_search_client'; +import { getNodes } from './lib/get_nodes'; const escapeHatch = schema.object({}, { unknowns: 'allow' }); @@ -30,43 +30,22 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { }, async (requestContext, request, response) => { try { - const { - filterQuery, - nodeType, - groupBy, - sourceId, - metrics, - timerange, - accountId, - region, - includeTimeseries, - overrideCompositeSize, - } = pipe( + const snapshotRequest = pipe( SnapshotRequestRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); + const source = await libs.sources.getSourceConfiguration( requestContext.core.savedObjects.client, - sourceId + snapshotRequest.sourceId ); - UsageCollector.countNode(nodeType); - const options = { - filterQuery: parseFilterQuery(filterQuery), - accountId, - region, - nodeType, - groupBy, - sourceConfiguration: source.configuration, - metrics, - timerange, - includeTimeseries, - overrideCompositeSize, - }; + UsageCollector.countNode(snapshotRequest.nodeType); const client = createSearchClient(requestContext, framework); - const nodesWithInterval = await libs.snapshot.getNodes(client, options); + const snapshotResponse = await getNodes(client, snapshotRequest, source); + return response.ok({ - body: SnapshotNodeResponseRT.encode(nodesWithInterval), + body: SnapshotNodeResponseRT.encode(snapshotResponse), }); } catch (error) { return response.internalError({ diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/apply_metadata_to_last_path.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/apply_metadata_to_last_path.ts new file mode 100644 index 0000000000000..f41d76bbc156f --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/apply_metadata_to_last_path.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, last, first, isArray } from 'lodash'; +import { findInventoryFields } from '../../../../common/inventory_models'; +import { + SnapshotRequest, + SnapshotNodePath, + SnapshotNode, + MetricsAPISeries, + MetricsAPIRow, +} from '../../../../common/http_api'; +import { META_KEY } from './constants'; +import { InfraSource } from '../../../lib/sources'; + +export const isIPv4 = (subject: string) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(subject); + +type RowWithMetadata = MetricsAPIRow & { + [META_KEY]: object[]; +}; + +export const applyMetadataToLastPath = ( + series: MetricsAPISeries, + node: SnapshotNode, + snapshotRequest: SnapshotRequest, + source: InfraSource +): SnapshotNodePath[] => { + // First we need to find a row with metadata + const rowWithMeta = series.rows.find( + (row) => (row[META_KEY] && isArray(row[META_KEY]) && (row[META_KEY] as object[]).length) || 0 + ) as RowWithMetadata | undefined; + + if (rowWithMeta) { + // We need just the first doc, there should only be one + const firstMetaDoc = first(rowWithMeta[META_KEY]); + // We also need the last path to add the metadata to + const lastPath = last(node.path); + if (firstMetaDoc && lastPath) { + // We will need the inventory fields so we can use the field paths to get + // the values from the metadata document + const inventoryFields = findInventoryFields( + snapshotRequest.nodeType, + source.configuration.fields + ); + // Set the label as the name and fallback to the id OR path.value + lastPath.label = get(firstMetaDoc, inventoryFields.name, lastPath.value); + // If the inventory fields contain an ip address, we need to try and set that + // on the path object. IP addersses are typically stored as multiple fields. We will + // use the first IPV4 address we find. + if (inventoryFields.ip) { + const ipAddresses = get(firstMetaDoc, inventoryFields.ip) as string[]; + if (Array.isArray(ipAddresses)) { + lastPath.ip = ipAddresses.find(isIPv4) || null; + } else if (typeof ipAddresses === 'string') { + lastPath.ip = ipAddresses; + } + } + return [...node.path.slice(0, node.path.length - 1), lastPath]; + } + } + return node.path; +}; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.ts new file mode 100644 index 0000000000000..4218aecfe74a8 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/calculate_index_pattern_based_on_metrics.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SnapshotRequest } from '../../../../common/http_api'; +import { InfraSource } from '../../../lib/sources'; + +export const calculateIndexPatterBasedOnMetrics = ( + options: SnapshotRequest, + source: InfraSource +) => { + const { metrics } = options; + if (metrics.every((m) => m.type === 'logRate')) { + return source.configuration.logAlias; + } + if (metrics.some((m) => m.type === 'logRate')) { + return `${source.configuration.logAlias},${source.configuration.metricAlias}`; + } + return source.configuration.metricAlias; +}; diff --git a/x-pack/plugins/infra/server/lib/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/constants.ts similarity index 85% rename from x-pack/plugins/infra/server/lib/snapshot/index.ts rename to x-pack/plugins/infra/server/routes/snapshot/lib/constants.ts index 8db54da803648..563c720224435 100644 --- a/x-pack/plugins/infra/server/lib/snapshot/index.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/constants.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './snapshot'; +export const META_KEY = '__metadata__'; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/copy_missing_metrics.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/copy_missing_metrics.ts new file mode 100644 index 0000000000000..36397862e4153 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/copy_missing_metrics.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { memoize, last, first } from 'lodash'; +import { SnapshotNode, SnapshotNodeResponse } from '../../../../common/http_api'; + +const createMissingMetricFinder = (nodes: SnapshotNode[]) => + memoize((id: string) => { + const nodeWithMetrics = nodes.find((node) => { + const lastPath = last(node.path); + const metric = first(node.metrics); + return lastPath && metric && lastPath.value === id && metric.value !== null; + }); + if (nodeWithMetrics) { + return nodeWithMetrics.metrics; + } + }); + +/** + * This function will look for nodes with missing data and try to find a node to copy the data from. + * This functionality exists to suppor the use case where the user requests a group by on "Service type". + * Since that grouping naturally excludeds every metric (except the metric for the service.type), we still + * want to display the node with a value. A good example is viewing hosts by CPU Usage and grouping by service + * Without this every service but `system` would be null. + */ +export const copyMissingMetrics = (response: SnapshotNodeResponse) => { + const { nodes } = response; + const find = createMissingMetricFinder(nodes); + const newNodes = nodes.map((node) => { + const lastPath = last(node.path); + const metric = first(node.metrics); + const allRowsNull = metric?.timeseries?.rows.every((r) => r.metric_0 == null) ?? true; + if (lastPath && metric && metric.value === null && allRowsNull) { + const newMetrics = find(lastPath.value); + if (newMetrics) { + return { ...node, metrics: newMetrics }; + } + } + return node; + }); + return { ...response, nodes: newNodes }; +}; diff --git a/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts similarity index 80% rename from x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts rename to x-pack/plugins/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts index 719ffdb8fa7c4..827e0901c1c01 100644 --- a/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts @@ -5,14 +5,16 @@ */ import { uniq } from 'lodash'; -import { InfraSnapshotRequestOptions } from './types'; -import { getMetricsAggregations } from './query_helpers'; -import { calculateMetricInterval } from '../../utils/calculate_metric_interval'; -import { MetricsUIAggregation, ESBasicMetricAggRT } from '../../../common/inventory_models/types'; -import { getDatasetForField } from '../../routes/metrics_explorer/lib/get_dataset_for_field'; -import { InfraTimerangeInput } from '../../../common/http_api/snapshot_api'; -import { ESSearchClient } from '.'; -import { getIntervalInSeconds } from '../../utils/get_interval_in_seconds'; +import { InfraTimerangeInput } from '../../../../common/http_api'; +import { ESSearchClient } from '../../../lib/metrics/types'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { calculateMetricInterval } from '../../../utils/calculate_metric_interval'; +import { getMetricsAggregations, InfraSnapshotRequestOptions } from './get_metrics_aggregations'; +import { + MetricsUIAggregation, + ESBasicMetricAggRT, +} from '../../../../common/inventory_models/types'; +import { getDatasetForField } from '../../metrics_explorer/lib/get_dataset_for_field'; const createInterval = async (client: ESSearchClient, options: InfraSnapshotRequestOptions) => { const { timerange } = options; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/get_metrics_aggregations.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/get_metrics_aggregations.ts new file mode 100644 index 0000000000000..2421469eb1bdd --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/get_metrics_aggregations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { JsonObject } from '../../../../common/typed_json'; +import { + InventoryItemType, + MetricsUIAggregation, + MetricsUIAggregationRT, +} from '../../../../common/inventory_models/types'; +import { + SnapshotMetricInput, + SnapshotCustomMetricInputRT, + SnapshotRequest, +} from '../../../../common/http_api'; +import { findInventoryModel } from '../../../../common/inventory_models'; +import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; +import { InfraSourceConfiguration } from '../../../lib/sources'; + +export interface InfraSnapshotRequestOptions + extends Omit { + sourceConfiguration: InfraSourceConfiguration; + filterQuery: JsonObject | undefined; +} + +export const metricToAggregation = ( + nodeType: InventoryItemType, + metric: SnapshotMetricInput, + index: number +) => { + const inventoryModel = findInventoryModel(nodeType); + if (SnapshotCustomMetricInputRT.is(metric)) { + if (metric.aggregation === 'rate') { + return networkTraffic(`custom_${index}`, metric.field); + } + return { + [`custom_${index}`]: { + [metric.aggregation]: { + field: metric.field, + }, + }, + }; + } + return inventoryModel.metrics.snapshot?.[metric.type]; +}; + +export const getMetricsAggregations = ( + options: InfraSnapshotRequestOptions +): MetricsUIAggregation => { + const { metrics } = options; + return metrics.reduce((aggs, metric, index) => { + const aggregation = metricToAggregation(options.nodeType, metric, index); + if (!MetricsUIAggregationRT.is(aggregation)) { + throw new Error( + i18n.translate('xpack.infra.snapshot.missingSnapshotMetricError', { + defaultMessage: 'The aggregation for {metric} for {nodeType} is not available.', + values: { + nodeType: options.nodeType, + metric: metric.type, + }, + }) + ); + } + return { ...aggs, ...aggregation }; + }, {}); +}; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts new file mode 100644 index 0000000000000..9332d5aee1f52 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/get_nodes.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SnapshotRequest } from '../../../../common/http_api'; +import { ESSearchClient } from '../../../lib/metrics/types'; +import { InfraSource } from '../../../lib/sources'; +import { transformRequestToMetricsAPIRequest } from './transform_request_to_metrics_api_request'; +import { queryAllData } from './query_all_data'; +import { transformMetricsApiResponseToSnapshotResponse } from './trasform_metrics_ui_response'; +import { copyMissingMetrics } from './copy_missing_metrics'; + +export const getNodes = async ( + client: ESSearchClient, + snapshotRequest: SnapshotRequest, + source: InfraSource +) => { + const metricsApiRequest = await transformRequestToMetricsAPIRequest( + client, + source, + snapshotRequest + ); + const metricsApiResponse = await queryAllData(client, metricsApiRequest); + return copyMissingMetrics( + transformMetricsApiResponseToSnapshotResponse( + metricsApiRequest, + snapshotRequest, + source, + metricsApiResponse + ) + ); +}; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/query_all_data.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/query_all_data.ts new file mode 100644 index 0000000000000..a9d2352cf55b7 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/query_all_data.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MetricsAPIRequest, MetricsAPIResponse } from '../../../../common/http_api'; +import { ESSearchClient } from '../../../lib/metrics/types'; +import { query } from '../../../lib/metrics'; + +const handleResponse = ( + client: ESSearchClient, + options: MetricsAPIRequest, + previousResponse?: MetricsAPIResponse +) => async (resp: MetricsAPIResponse): Promise => { + const combinedResponse = previousResponse + ? { + ...previousResponse, + series: [...previousResponse.series, ...resp.series], + info: resp.info, + } + : resp; + if (resp.info.afterKey) { + return query(client, { ...options, afterKey: resp.info.afterKey }).then( + handleResponse(client, options, combinedResponse) + ); + } + return combinedResponse; +}; + +export const queryAllData = (client: ESSearchClient, options: MetricsAPIRequest) => { + return query(client, options).then(handleResponse(client, options)); +}; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.ts new file mode 100644 index 0000000000000..700f4ef39bb66 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_request_to_metrics_api_request.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { findInventoryFields } from '../../../../common/inventory_models'; +import { MetricsAPIRequest, SnapshotRequest } from '../../../../common/http_api'; +import { ESSearchClient } from '../../../lib/metrics/types'; +import { InfraSource } from '../../../lib/sources'; +import { createTimeRangeWithInterval } from './create_timerange_with_interval'; +import { parseFilterQuery } from '../../../utils/serialized_query'; +import { transformSnapshotMetricsToMetricsAPIMetrics } from './transform_snapshot_metrics_to_metrics_api_metrics'; +import { calculateIndexPatterBasedOnMetrics } from './calculate_index_pattern_based_on_metrics'; +import { META_KEY } from './constants'; + +export const transformRequestToMetricsAPIRequest = async ( + client: ESSearchClient, + source: InfraSource, + snapshotRequest: SnapshotRequest +): Promise => { + const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(client, { + ...snapshotRequest, + filterQuery: parseFilterQuery(snapshotRequest.filterQuery), + sourceConfiguration: source.configuration, + }); + + const metricsApiRequest: MetricsAPIRequest = { + indexPattern: calculateIndexPatterBasedOnMetrics(snapshotRequest, source), + timerange: { + field: source.configuration.fields.timestamp, + from: timeRangeWithIntervalApplied.from, + to: timeRangeWithIntervalApplied.to, + interval: timeRangeWithIntervalApplied.interval, + }, + metrics: transformSnapshotMetricsToMetricsAPIMetrics(snapshotRequest), + limit: snapshotRequest.overrideCompositeSize ? snapshotRequest.overrideCompositeSize : 10, + alignDataToEnd: true, + }; + + const filters = []; + const parsedFilters = parseFilterQuery(snapshotRequest.filterQuery); + if (parsedFilters) { + filters.push(parsedFilters); + } + + if (snapshotRequest.accountId) { + filters.push({ term: { 'cloud.account.id': snapshotRequest.accountId } }); + } + + if (snapshotRequest.region) { + filters.push({ term: { 'cloud.region': snapshotRequest.region } }); + } + + const inventoryFields = findInventoryFields( + snapshotRequest.nodeType, + source.configuration.fields + ); + const groupBy = snapshotRequest.groupBy.map((g) => g.field).filter(Boolean) as string[]; + metricsApiRequest.groupBy = [...groupBy, inventoryFields.id]; + + const metaAggregation = { + id: META_KEY, + aggregations: { + [META_KEY]: { + top_hits: { + size: 1, + _source: [inventoryFields.name], + sort: [{ [source.configuration.fields.timestamp]: 'desc' }], + }, + }, + }, + }; + if (inventoryFields.ip) { + metaAggregation.aggregations[META_KEY].top_hits._source.push(inventoryFields.ip); + } + metricsApiRequest.metrics.push(metaAggregation); + + if (filters.length) { + metricsApiRequest.filters = filters; + } + + return metricsApiRequest; +}; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts new file mode 100644 index 0000000000000..6f7c88eda5d7a --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/transform_snapshot_metrics_to_metrics_api_metrics.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; +import { findInventoryModel } from '../../../../common/inventory_models'; +import { + MetricsAPIMetric, + SnapshotRequest, + SnapshotCustomMetricInputRT, +} from '../../../../common/http_api'; + +export const transformSnapshotMetricsToMetricsAPIMetrics = ( + snapshotRequest: SnapshotRequest +): MetricsAPIMetric[] => { + return snapshotRequest.metrics.map((metric, index) => { + const inventoryModel = findInventoryModel(snapshotRequest.nodeType); + if (SnapshotCustomMetricInputRT.is(metric)) { + const customId = `custom_${index}`; + if (metric.aggregation === 'rate') { + return { id: customId, aggregations: networkTraffic(customId, metric.field) }; + } + return { + id: customId, + aggregations: { + [customId]: { + [metric.aggregation]: { + field: metric.field, + }, + }, + }, + }; + } + return { id: metric.type, aggregations: inventoryModel.metrics.snapshot?.[metric.type] }; + }); +}; diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/trasform_metrics_ui_response.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/trasform_metrics_ui_response.ts new file mode 100644 index 0000000000000..309598d71c361 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/trasform_metrics_ui_response.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, max, sum, last, isNumber } from 'lodash'; +import { SnapshotMetricType } from '../../../../common/inventory_models/types'; +import { + MetricsAPIResponse, + SnapshotNodeResponse, + MetricsAPIRequest, + MetricsExplorerColumnType, + MetricsAPIRow, + SnapshotRequest, + SnapshotNodePath, + SnapshotNodeMetric, +} from '../../../../common/http_api'; +import { META_KEY } from './constants'; +import { InfraSource } from '../../../lib/sources'; +import { applyMetadataToLastPath } from './apply_metadata_to_last_path'; + +const getMetricValue = (row: MetricsAPIRow) => { + if (!isNumber(row.metric_0)) return null; + const value = row.metric_0; + return isFinite(value) ? value : null; +}; + +const calculateMax = (rows: MetricsAPIRow[]) => { + return max(rows.map(getMetricValue)) || 0; +}; + +const calculateAvg = (rows: MetricsAPIRow[]): number => { + return sum(rows.map(getMetricValue)) / rows.length || 0; +}; + +const getLastValue = (rows: MetricsAPIRow[]) => { + const row = last(rows); + if (!row) return null; + return getMetricValue(row); +}; + +export const transformMetricsApiResponseToSnapshotResponse = ( + options: MetricsAPIRequest, + snapshotRequest: SnapshotRequest, + source: InfraSource, + metricsApiResponse: MetricsAPIResponse +): SnapshotNodeResponse => { + const nodes = metricsApiResponse.series.map((series) => { + const node = { + metrics: options.metrics + .filter((m) => m.id !== META_KEY) + .map((metric) => { + const name = metric.id as SnapshotMetricType; + const timeseries = { + id: name, + columns: [ + { name: 'timestamp', type: 'date' as MetricsExplorerColumnType }, + { name: 'metric_0', type: 'number' as MetricsExplorerColumnType }, + ], + rows: series.rows.map((row) => { + return { timestamp: row.timestamp, metric_0: get(row, metric.id, null) }; + }), + }; + const maxValue = calculateMax(timeseries.rows); + const avg = calculateAvg(timeseries.rows); + const value = getLastValue(timeseries.rows); + const nodeMetric: SnapshotNodeMetric = { name, max: maxValue, value, avg }; + if (snapshotRequest.includeTimeseries) { + nodeMetric.timeseries = timeseries; + } + return nodeMetric; + }), + path: + series.keys?.map((key) => { + return { value: key, label: key } as SnapshotNodePath; + }) ?? [], + name: '', + }; + + const path = applyMetadataToLastPath(series, node, snapshotRequest, source); + const lastPath = last(path); + const name = (lastPath && lastPath.label) || 'N/A'; + return { ...node, path, name }; + }); + return { nodes, interval: `${metricsApiResponse.info.interval}s` }; +}; diff --git a/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts b/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts index a3d674b324ae8..6d16e045d26d5 100644 --- a/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts +++ b/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts @@ -8,7 +8,7 @@ import { findInventoryModel } from '../../common/inventory_models'; // import { KibanaFramework } from '../lib/adapters/framework/kibana_framework_adapter'; import { InventoryItemType } from '../../common/inventory_models/types'; -import { ESSearchClient } from '../lib/snapshot'; +import { ESSearchClient } from '../lib/metrics/types'; interface Options { indexPattern: string; diff --git a/x-pack/test/api_integration/apis/metrics_ui/snapshot.ts b/x-pack/test/api_integration/apis/metrics_ui/snapshot.ts index bb0934b73a4c7..7339c142fb028 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/snapshot.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/snapshot.ts @@ -67,7 +67,6 @@ export default function ({ getService }: FtrProviderContext) { 'value', '242fddb9d376bbf0e38025d81764847ee5ec0308adfa095918fd3266f9d06c6a' ); - expect(first(firstNode.path)).to.have.property('label', 'docker-autodiscovery_nginx_1'); expect(firstNode).to.have.property('metrics'); expect(firstNode.metrics).to.eql([ { @@ -136,7 +135,7 @@ export default function ({ getService }: FtrProviderContext) { expect(snapshot).to.have.property('nodes'); if (snapshot) { const { nodes } = snapshot; - expect(nodes.length).to.equal(136); + expect(nodes.length).to.equal(135); const firstNode = first(nodes) as any; expect(firstNode).to.have.property('path'); expect(firstNode.path.length).to.equal(1); @@ -295,7 +294,7 @@ export default function ({ getService }: FtrProviderContext) { expect(firstNode).to.have.property('metrics'); expect(firstNode.metrics).to.eql([ { - name: 'custom', + name: 'custom_0', value: 0.0016, max: 0.0018333333333333333, avg: 0.0013666666666666669, From 86582a5501de102162132e475c3d9f4178b4f4e3 Mon Sep 17 00:00:00 2001 From: James Rodewig <40268737+jrodewig@users.noreply.github.com> Date: Wed, 9 Sep 2020 12:01:28 -0400 Subject: [PATCH 06/25] [Ingest Pipelines] Add descriptions for ingest processors K-S (#76981) --- .../manage_processor_form/processors/kv.tsx | 38 +++++++++------ .../processors/lowercase.tsx | 14 +----- .../processors/pipeline.tsx | 2 +- .../processors/remove.tsx | 2 +- .../processors/rename.tsx | 4 +- .../processors/script.tsx | 8 ++-- .../manage_processor_form/processors/set.tsx | 21 ++++++--- .../processors/set_security_user.tsx | 4 +- .../manage_processor_form/processors/sort.tsx | 5 +- .../processors/split.tsx | 6 +-- .../shared/map_processor_type_to_form.tsx | 47 +++++++++++++++---- 11 files changed, 94 insertions(+), 57 deletions(-) diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx index f51bf19ad180a..4104e8f727ab1 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx @@ -33,9 +33,15 @@ const fieldsConfig: FieldsConfig = { label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.fieldSplitFieldLabel', { defaultMessage: 'Field split', }), - helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.fieldSplitHelpText', { - defaultMessage: 'Regex pattern for splitting key-value pairs.', - }), + helpText: ( + {'" "'}, + }} + /> + ), validations: [ { validator: emptyField( @@ -52,9 +58,15 @@ const fieldsConfig: FieldsConfig = { label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.valueSplitFieldLabel', { defaultMessage: 'Value split', }), - helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.valueSplitHelpText', { - defaultMessage: 'Regex pattern for splitting the key from the value within a key-value pair.', - }), + helpText: ( + {'"="'}, + }} + /> + ), validations: [ { validator: emptyField( @@ -75,8 +87,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Include keys', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.includeKeysHelpText', { - defaultMessage: - 'List of keys to filter and insert into document. Defaults to including all keys.', + defaultMessage: 'List of extracted keys to include in the output. Defaults to all keys.', }), }, @@ -88,7 +99,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Exclude keys', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.excludeKeysHelpText', { - defaultMessage: 'List of keys to exclude from document.', + defaultMessage: 'List of extracted keys to exclude from the output.', }), }, @@ -99,7 +110,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Prefix', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.prefixHelpText', { - defaultMessage: 'Prefix to be added to extracted keys.', + defaultMessage: 'Prefix to add to extracted keys.', }), }, @@ -136,7 +147,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'()'}, angle: <>, @@ -154,7 +165,7 @@ export const Kv: FunctionComponent = () => { <> @@ -166,8 +177,7 @@ export const Kv: FunctionComponent = () => { helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.kvForm.targetFieldHelpText', { - defaultMessage: - 'Field to insert the extracted keys into. Defaults to the root of the document.', + defaultMessage: 'Output field for the extracted fields. Defaults to the document root.', } )} /> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx index 9db313a05007f..0d8170338ea10 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx @@ -6,8 +6,6 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCode } from '@elastic/eui'; import { FieldNameField } from './common_fields/field_name_field'; import { TargetField } from './common_fields/target_field'; @@ -23,17 +21,7 @@ export const Lowercase: FunctionComponent = () => { )} /> - {'field'}, - }} - /> - } - /> + diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx index c785cf935833d..57843e2411359 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx @@ -27,7 +27,7 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.pipelineForm.pipelineNameFieldHelpText', { - defaultMessage: 'Name of the pipeline to execute.', + defaultMessage: 'Name of the ingest pipeline to run.', } ), validations: [ diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx index 3e90ce2b76f7b..3ba1cdb0c802d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx @@ -29,7 +29,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Fields', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.removeForm.fieldNameHelpText', { - defaultMessage: 'Fields to be removed.', + defaultMessage: 'Fields to remove.', }), validations: [ { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx index 8b796d9664586..099e2bd2c80fb 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx @@ -21,7 +21,7 @@ export const Rename: FunctionComponent = () => { @@ -31,7 +31,7 @@ export const Rename: FunctionComponent = () => { })} helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.renameForm.targetFieldHelpText', - { defaultMessage: 'Name of the new field.' } + { defaultMessage: 'New field name. This field cannot already exist.' } )} validations={[ { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx index ae0bbbb490ae9..de28f66766603 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx @@ -32,7 +32,7 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.scriptForm.storedScriptIDFieldHelpText', { - defaultMessage: 'Stored script reference.', + defaultMessage: 'ID of the stored script to run.', } ), validations: [ @@ -55,7 +55,7 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.scriptForm.sourceFieldHelpText', { - defaultMessage: 'Script to be executed.', + defaultMessage: 'Inline script to run.', } ), validations: [ @@ -98,7 +98,7 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.scriptForm.paramsFieldHelpText', { - defaultMessage: 'Script parameters.', + defaultMessage: 'Named parameters passed to the script as variables.', } ), validations: [ @@ -128,7 +128,7 @@ export const Script: FormFieldsComponent = ({ initialFieldValues }) => { setShowId((v) => !v)} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx index c282be35e5071..04ea0c44c3513 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx @@ -32,13 +32,13 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Value', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueFieldHelpText', { - defaultMessage: 'Value to be set for the field', + defaultMessage: 'Value for the field.', }), validations: [ { validator: emptyField( i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueRequiredError', { - defaultMessage: 'A value is required', + defaultMessage: 'A value is required.', }) ), }, @@ -53,9 +53,15 @@ const fieldsConfig: FieldsConfig = { label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldLabel', { defaultMessage: 'Override', }), - helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldHelpText', { - defaultMessage: 'If disabled, fields containing non-null values will not be updated.', - }), + helpText: ( + {'null'}, + }} + /> + ), }, ignore_empty_value: { type: FIELD_TYPES.TOGGLE, @@ -71,7 +77,8 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'value'}, nullValue: {'null'}, @@ -89,7 +96,7 @@ export const SetProcessor: FunctionComponent = () => { <> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx index 78128b3d54c75..46bfe8c97ebea 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx @@ -44,7 +44,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( [{helpTextValues}], }} @@ -60,7 +60,7 @@ export const SetSecurityUser: FunctionComponent = () => { helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.setSecurityUserForm.fieldNameField', { - defaultMessage: 'Field to store the user information', + defaultMessage: 'Output field.', } )} /> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx index cdd0ff888accf..c8c0562011fd6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx @@ -24,7 +24,8 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Order', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.sortForm.orderFieldHelpText', { - defaultMessage: 'Sort order to use', + defaultMessage: + 'Sort order. Arrays containing a mix of strings and numbers are sorted lexicographically.', }), }, }; @@ -35,7 +36,7 @@ export const Sort: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx index b48ce74110b39..fa178aaddd314 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx @@ -33,7 +33,7 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.splitForm.separatorFieldHelpText', { - defaultMessage: 'Regex to match a separator', + defaultMessage: 'Regex pattern used to delimit the field value.', } ), validations: [ @@ -60,7 +60,7 @@ const fieldsConfig: FieldsConfig = { ), helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.splitForm.preserveTrailingFieldHelpText', - { defaultMessage: 'If enabled, preserve any trailing space.' } + { defaultMessage: 'Preserve any trailing whitespace in the split field values.' } ), }, }; @@ -71,7 +71,7 @@ export const Split: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx index 59ec64944a3c9..9de371f8d0024 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx @@ -107,7 +107,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { defaultMessage: 'CSV', }), description: i18n.translate('xpack.ingestPipelines.processors.description.csv', { - defaultMessage: 'Extracts fields values from CSV data.', + defaultMessage: 'Extracts field values from CSV data.', }), }, date: { @@ -306,7 +306,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { FieldsComponent: Kv, docLinkPath: '/kv-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.kv', { - defaultMessage: 'KV', + defaultMessage: 'Key-value (KV)', + }), + description: i18n.translate('xpack.ingestPipelines.processors.description.kv', { + defaultMessage: 'Extracts fields from a string containing key-value pairs.', }), }, lowercase: { @@ -315,6 +318,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.lowercase', { defaultMessage: 'Lowercase', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.lowercase', { + defaultMessage: 'Converts a string to lowercase.', + }), }, pipeline: { FieldsComponent: Pipeline, @@ -322,6 +328,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.pipeline', { defaultMessage: 'Pipeline', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.pipeline', { + defaultMessage: 'Runs another ingest node pipeline.', + }), }, remove: { FieldsComponent: Remove, @@ -329,6 +338,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.remove', { defaultMessage: 'Remove', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.remove', { + defaultMessage: 'Removes one or more fields.', + }), }, rename: { FieldsComponent: Rename, @@ -336,6 +348,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.rename', { defaultMessage: 'Rename', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.rename', { + defaultMessage: 'Renames an existing field.', + }), }, script: { FieldsComponent: Script, @@ -343,6 +358,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.script', { defaultMessage: 'Script', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.script', { + defaultMessage: 'Runs a script on incoming documents.', + }), }, set: { FieldsComponent: SetProcessor, @@ -350,6 +368,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.set', { defaultMessage: 'Set', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.set', { + defaultMessage: 'Sets the value of a field.', + }), }, set_security_user: { FieldsComponent: SetSecurityUser, @@ -357,12 +378,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.setSecurityUser', { defaultMessage: 'Set security user', }), - }, - split: { - FieldsComponent: Split, - docLinkPath: '/split-processor.html', - label: i18n.translate('xpack.ingestPipelines.processors.label.split', { - defaultMessage: 'Split', + description: i18n.translate('xpack.ingestPipelines.processors.description.setSecurityUser', { + defaultMessage: + 'Adds details about the current user, such user name and email address, to incoming documents. Requires an authenticated user for the indexing request.', }), }, sort: { @@ -371,6 +389,19 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.sort', { defaultMessage: 'Sort', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.sort', { + defaultMessage: "Sorts a field's array elements.", + }), + }, + split: { + FieldsComponent: Split, + docLinkPath: '/split-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.split', { + defaultMessage: 'Split', + }), + description: i18n.translate('xpack.ingestPipelines.processors.description.split', { + defaultMessage: 'Splits a field value into an array.', + }), }, trim: { FieldsComponent: undefined, // TODO: Implement From 20b2e31debf59ee0a0d368fd5ab4f4c790f94c39 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Wed, 9 Sep 2020 18:18:25 +0200 Subject: [PATCH 07/25] Bump eventemitter3 from 4.0.0 to 4.0.7 (#77016) --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 29f99c25b7730..105c5e3cba5ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12288,9 +12288,9 @@ eventemitter2@~0.4.13: integrity sha1-j2G3XN4BKy6esoTUVFWDtWQ7Yas= eventemitter3@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" - integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== events@^1.0.2: version "1.1.1" From 619108cac345ed02928e96e54a84cefef2ddab72 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Wed, 9 Sep 2020 11:41:52 -0500 Subject: [PATCH 08/25] [ML] Add decision path charts to exploration results table (#73561) Co-authored-by: Elastic Machine --- .../common/constants/data_frame_analytics.ts | 7 + .../ml/common/types/data_frame_analytics.ts | 6 + .../ml/common/types/feature_importance.ts | 23 +++ .../plugins/ml/common/util/analytics_utils.ts | 79 ++++++++ .../components/data_grid/common.ts | 13 +- .../components/data_grid/data_grid.tsx | 58 +++++- .../decision_path_chart.tsx | 166 +++++++++++++++++ .../decision_path_classification.tsx | 105 +++++++++++ .../decision_path_json_viewer.tsx | 16 ++ .../decision_path_popover.tsx | 134 ++++++++++++++ .../decision_path_regression.tsx | 79 ++++++++ .../missing_decision_path_callout.tsx | 20 ++ .../use_classification_path_data.tsx | 173 ++++++++++++++++++ .../application/components/data_grid/types.ts | 6 + .../data_frame_analytics/common/analytics.ts | 91 ++------- .../data_frame_analytics/common/constants.ts | 2 - .../data_frame_analytics/common/fields.ts | 7 +- .../classification_exploration.tsx | 1 - .../exploration_page_wrapper.tsx | 1 - .../exploration_results_table.tsx | 16 +- .../use_exploration_results.ts | 68 ++++++- .../outlier_exploration/use_outlier_data.ts | 3 +- .../action_clone/clone_action_name.tsx | 2 +- .../ml_api_service/data_frame_analytics.ts | 6 + .../feature_importance.ts | 69 +++++++ .../ml/server/routes/data_frame_analytics.ts | 35 ++++ .../ml/data_frame_analytics_creation.ts | 22 +-- 27 files changed, 1083 insertions(+), 125 deletions(-) create mode 100644 x-pack/plugins/ml/common/constants/data_frame_analytics.ts create mode 100644 x-pack/plugins/ml/common/types/feature_importance.ts create mode 100644 x-pack/plugins/ml/common/util/analytics_utils.ts create mode 100644 x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx create mode 100644 x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_classification.tsx create mode 100644 x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_json_viewer.tsx create mode 100644 x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx create mode 100644 x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_regression.tsx create mode 100644 x-pack/plugins/ml/public/application/components/data_grid/feature_importance/missing_decision_path_callout.tsx create mode 100644 x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.tsx create mode 100644 x-pack/plugins/ml/server/models/data_frame_analytics/feature_importance.ts diff --git a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts new file mode 100644 index 0000000000000..830537cbadbc8 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DEFAULT_RESULTS_FIELD = 'ml'; diff --git a/x-pack/plugins/ml/common/types/data_frame_analytics.ts b/x-pack/plugins/ml/common/types/data_frame_analytics.ts index f0aac75047585..60d2ca63dda59 100644 --- a/x-pack/plugins/ml/common/types/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/types/data_frame_analytics.ts @@ -79,3 +79,9 @@ export interface DataFrameAnalyticsConfig { version: string; allow_lazy_start?: boolean; } + +export enum ANALYSIS_CONFIG_TYPE { + OUTLIER_DETECTION = 'outlier_detection', + REGRESSION = 'regression', + CLASSIFICATION = 'classification', +} diff --git a/x-pack/plugins/ml/common/types/feature_importance.ts b/x-pack/plugins/ml/common/types/feature_importance.ts new file mode 100644 index 0000000000000..d2ab9f6c58608 --- /dev/null +++ b/x-pack/plugins/ml/common/types/feature_importance.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ClassFeatureImportance { + class_name: string | boolean; + importance: number; +} +export interface FeatureImportance { + feature_name: string; + importance?: number; + classes?: ClassFeatureImportance[]; +} + +export interface TopClass { + class_name: string; + class_probability: number; + class_score: number; +} + +export type TopClasses = TopClass[]; diff --git a/x-pack/plugins/ml/common/util/analytics_utils.ts b/x-pack/plugins/ml/common/util/analytics_utils.ts new file mode 100644 index 0000000000000..d725984a47d66 --- /dev/null +++ b/x-pack/plugins/ml/common/util/analytics_utils.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AnalysisConfig, + ClassificationAnalysis, + OutlierAnalysis, + RegressionAnalysis, + ANALYSIS_CONFIG_TYPE, +} from '../types/data_frame_analytics'; + +export const isOutlierAnalysis = (arg: any): arg is OutlierAnalysis => { + const keys = Object.keys(arg); + return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION; +}; + +export const isRegressionAnalysis = (arg: any): arg is RegressionAnalysis => { + const keys = Object.keys(arg); + return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.REGRESSION; +}; + +export const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => { + const keys = Object.keys(arg); + return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; +}; + +export const getDependentVar = ( + analysis: AnalysisConfig +): + | RegressionAnalysis['regression']['dependent_variable'] + | ClassificationAnalysis['classification']['dependent_variable'] => { + let depVar = ''; + + if (isRegressionAnalysis(analysis)) { + depVar = analysis.regression.dependent_variable; + } + + if (isClassificationAnalysis(analysis)) { + depVar = analysis.classification.dependent_variable; + } + return depVar; +}; + +export const getPredictionFieldName = ( + analysis: AnalysisConfig +): + | RegressionAnalysis['regression']['prediction_field_name'] + | ClassificationAnalysis['classification']['prediction_field_name'] => { + // If undefined will be defaulted to dependent_variable when config is created + let predictionFieldName; + if (isRegressionAnalysis(analysis) && analysis.regression.prediction_field_name !== undefined) { + predictionFieldName = analysis.regression.prediction_field_name; + } else if ( + isClassificationAnalysis(analysis) && + analysis.classification.prediction_field_name !== undefined + ) { + predictionFieldName = analysis.classification.prediction_field_name; + } + return predictionFieldName; +}; + +export const getDefaultPredictionFieldName = (analysis: AnalysisConfig) => { + return `${getDependentVar(analysis)}_prediction`; +}; +export const getPredictedFieldName = ( + resultsField: string, + analysis: AnalysisConfig, + forSort?: boolean +) => { + // default is 'ml' + const predictionFieldName = getPredictionFieldName(analysis); + const predictedField = `${resultsField}.${ + predictionFieldName ? predictionFieldName : getDefaultPredictionFieldName(analysis) + }`; + return predictedField; +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts index 1f0fcb63f019d..f252729cc20cd 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts @@ -119,13 +119,14 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results schema = 'numeric'; } - if ( - field.includes(`${resultsField}.${FEATURE_IMPORTANCE}`) || - field.includes(`${resultsField}.${TOP_CLASSES}`) - ) { + if (field.includes(`${resultsField}.${TOP_CLASSES}`)) { schema = 'json'; } + if (field.includes(`${resultsField}.${FEATURE_IMPORTANCE}`)) { + schema = 'featureImportance'; + } + return { id: field, schema, isSortable }; }); }; @@ -250,10 +251,6 @@ export const useRenderCellValue = ( return cellValue ? 'true' : 'false'; } - if (typeof cellValue === 'object' && cellValue !== null) { - return JSON.stringify(cellValue); - } - return cellValue; }; }, [indexPattern?.fields, pagination.pageIndex, pagination.pageSize, tableItems]); diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index d4be2eab13d26..22815fe593d57 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -5,8 +5,7 @@ */ import { isEqual } from 'lodash'; -import React, { memo, useEffect, FC } from 'react'; - +import React, { memo, useEffect, FC, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { @@ -24,13 +23,16 @@ import { } from '@elastic/eui'; import { CoreSetup } from 'src/core/public'; - import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../common/constants/field_histograms'; -import { INDEX_STATUS } from '../../data_frame_analytics/common'; +import { ANALYSIS_CONFIG_TYPE, INDEX_STATUS } from '../../data_frame_analytics/common'; import { euiDataGridStyle, euiDataGridToolbarSettings } from './common'; import { UseIndexDataReturnType } from './types'; +import { DecisionPathPopover } from './feature_importance/decision_path_popover'; +import { TopClasses } from '../../../../common/types/feature_importance'; +import { DEFAULT_RESULTS_FIELD } from '../../../../common/constants/data_frame_analytics'; + // TODO Fix row hovering + bar highlighting // import { hoveredRow$ } from './column_chart'; @@ -41,6 +43,9 @@ export const DataGridTitle: FC<{ title: string }> = ({ title }) => ( ); interface PropsWithoutHeader extends UseIndexDataReturnType { + baseline?: number; + analysisType?: ANALYSIS_CONFIG_TYPE; + resultsField?: string; dataTestSubj: string; toastNotifications: CoreSetup['notifications']['toasts']; } @@ -60,6 +65,7 @@ type Props = PropsWithHeader | PropsWithoutHeader; export const DataGrid: FC = memo( (props) => { const { + baseline, chartsVisible, chartsButtonVisible, columnsWithCharts, @@ -80,8 +86,10 @@ export const DataGrid: FC = memo( toastNotifications, toggleChartVisibility, visibleColumns, + predictionFieldName, + resultsField, + analysisType, } = props; - // TODO Fix row hovering + bar highlighting // const getRowProps = (item: any) => { // return { @@ -90,6 +98,45 @@ export const DataGrid: FC = memo( // }; // }; + const popOverContent = useMemo(() => { + return analysisType === ANALYSIS_CONFIG_TYPE.REGRESSION || + analysisType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION + ? { + featureImportance: ({ children }: { cellContentsElement: any; children: any }) => { + const rowIndex = children?.props?.visibleRowIndex; + const row = data[rowIndex]; + if (!row) return
; + // if resultsField for some reason is not available then use ml + const mlResultsField = resultsField ?? DEFAULT_RESULTS_FIELD; + const parsedFIArray = row[mlResultsField].feature_importance; + let predictedValue: string | number | undefined; + let topClasses: TopClasses = []; + if ( + predictionFieldName !== undefined && + row && + row[mlResultsField][predictionFieldName] !== undefined + ) { + predictedValue = row[mlResultsField][predictionFieldName]; + topClasses = row[mlResultsField].top_classes; + } + + return ( + + ); + }, + } + : undefined; + }, [baseline, data]); + useEffect(() => { if (invalidSortingColumnns.length > 0) { invalidSortingColumnns.forEach((columnId) => { @@ -225,6 +272,7 @@ export const DataGrid: FC = memo( } : {}), }} + popoverContents={popOverContent} pagination={{ ...pagination, pageSizeOptions: [5, 10, 25], diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx new file mode 100644 index 0000000000000..b546ac1db57dd --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AnnotationDomainTypes, + Axis, + AxisStyle, + Chart, + LineAnnotation, + LineAnnotationStyle, + LineAnnotationDatum, + LineSeries, + PartialTheme, + Position, + RecursivePartial, + ScaleType, + Settings, +} from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; + +import React, { useCallback, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import euiVars from '@elastic/eui/dist/eui_theme_light.json'; +import { DecisionPathPlotData } from './use_classification_path_data'; + +const { euiColorFullShade, euiColorMediumShade } = euiVars; +const axisColor = euiColorMediumShade; + +const baselineStyle: LineAnnotationStyle = { + line: { + strokeWidth: 1, + stroke: euiColorFullShade, + opacity: 0.75, + }, + details: { + fontFamily: 'Arial', + fontSize: 10, + fontStyle: 'bold', + fill: euiColorMediumShade, + padding: 0, + }, +}; + +const axes: RecursivePartial = { + axisLine: { + stroke: axisColor, + }, + tickLabel: { + fontSize: 10, + fill: axisColor, + }, + tickLine: { + stroke: axisColor, + }, + gridLine: { + horizontal: { + dash: [1, 2], + }, + vertical: { + strokeWidth: 0, + }, + }, +}; +const theme: PartialTheme = { + axes, +}; + +interface DecisionPathChartProps { + decisionPathData: DecisionPathPlotData; + predictionFieldName?: string; + baseline?: number; + minDomain: number | undefined; + maxDomain: number | undefined; +} + +const DECISION_PATH_MARGIN = 125; +const DECISION_PATH_ROW_HEIGHT = 10; +const NUM_PRECISION = 3; +const AnnotationBaselineMarker = ; + +export const DecisionPathChart = ({ + decisionPathData, + predictionFieldName, + minDomain, + maxDomain, + baseline, +}: DecisionPathChartProps) => { + // adjust the height so it's compact for items with more features + const baselineData: LineAnnotationDatum[] = useMemo( + () => [ + { + dataValue: baseline, + header: baseline ? baseline.toPrecision(NUM_PRECISION) : '', + details: i18n.translate( + 'xpack.ml.dataframe.analytics.explorationResults.decisionPathBaselineText', + { + defaultMessage: + 'baseline (average of predictions for all data points in the training data set)', + } + ), + }, + ], + [baseline] + ); + // guarantee up to num_precision significant digits + // without having it in scientific notation + const tickFormatter = useCallback((d) => Number(d.toPrecision(NUM_PRECISION)).toString(), []); + + return ( + + + {baseline && ( + + )} + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_classification.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_classification.tsx new file mode 100644 index 0000000000000..bd001fa81a582 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_classification.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiTitle } from '@elastic/eui'; +import d3 from 'd3'; +import { + isDecisionPathData, + useDecisionPathData, + getStringBasedClassName, +} from './use_classification_path_data'; +import { FeatureImportance, TopClasses } from '../../../../../common/types/feature_importance'; +import { DecisionPathChart } from './decision_path_chart'; +import { MissingDecisionPathCallout } from './missing_decision_path_callout'; + +interface ClassificationDecisionPathProps { + predictedValue: string | boolean; + predictionFieldName?: string; + featureImportance: FeatureImportance[]; + topClasses: TopClasses; +} + +export const ClassificationDecisionPath: FC = ({ + featureImportance, + predictedValue, + topClasses, + predictionFieldName, +}) => { + const [currentClass, setCurrentClass] = useState( + getStringBasedClassName(topClasses[0].class_name) + ); + const { decisionPathData } = useDecisionPathData({ + featureImportance, + predictedValue: currentClass, + }); + const options = useMemo(() => { + const predictionValueStr = getStringBasedClassName(predictedValue); + + return Array.isArray(topClasses) + ? topClasses.map((c) => { + const className = getStringBasedClassName(c.class_name); + return { + value: className, + inputDisplay: + className === predictionValueStr ? ( + + {className} + + ) : ( + className + ), + }; + }) + : undefined; + }, [topClasses, predictedValue]); + + const domain = useMemo(() => { + let maxDomain; + let minDomain; + // if decisionPathData has calculated cumulative path + if (decisionPathData && isDecisionPathData(decisionPathData)) { + const [min, max] = d3.extent(decisionPathData, (d: [string, number, number]) => d[2]); + const buffer = Math.abs(max - min) * 0.1; + maxDomain = max + buffer; + minDomain = min - buffer; + } + return { maxDomain, minDomain }; + }, [decisionPathData]); + + if (!decisionPathData) return ; + + return ( + <> + + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.explorationResults.classificationDecisionPathClassNameTitle', + { + defaultMessage: 'Class name', + } + )} + + + {options !== undefined && ( + + )} + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_json_viewer.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_json_viewer.tsx new file mode 100644 index 0000000000000..343324b27f9b5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_json_viewer.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiCodeBlock } from '@elastic/eui'; +import { FeatureImportance } from '../../../../../common/types/feature_importance'; + +interface DecisionPathJSONViewerProps { + featureImportance: FeatureImportance[]; +} +export const DecisionPathJSONViewer: FC = ({ featureImportance }) => { + return {JSON.stringify(featureImportance)}; +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx new file mode 100644 index 0000000000000..263337f93e9a8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState } from 'react'; +import { EuiLink, EuiTab, EuiTabs, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RegressionDecisionPath } from './decision_path_regression'; +import { DecisionPathJSONViewer } from './decision_path_json_viewer'; +import { FeatureImportance, TopClasses } from '../../../../../common/types/feature_importance'; +import { ANALYSIS_CONFIG_TYPE } from '../../../data_frame_analytics/common'; +import { ClassificationDecisionPath } from './decision_path_classification'; +import { useMlKibana } from '../../../contexts/kibana'; + +interface DecisionPathPopoverProps { + featureImportance: FeatureImportance[]; + analysisType: ANALYSIS_CONFIG_TYPE; + predictionFieldName?: string; + baseline?: number; + predictedValue?: number | string | undefined; + topClasses?: TopClasses; +} + +enum DECISION_PATH_TABS { + CHART = 'decision_path_chart', + JSON = 'decision_path_json', +} + +export interface ExtendedFeatureImportance extends FeatureImportance { + absImportance?: number; +} + +export const DecisionPathPopover: FC = ({ + baseline, + featureImportance, + predictedValue, + topClasses, + analysisType, + predictionFieldName, +}) => { + const [selectedTabId, setSelectedTabId] = useState(DECISION_PATH_TABS.CHART); + const { + services: { docLinks }, + } = useMlKibana(); + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; + + if (featureImportance.length < 2) { + return ; + } + + const tabs = [ + { + id: DECISION_PATH_TABS.CHART, + name: ( + + ), + }, + { + id: DECISION_PATH_TABS.JSON, + name: ( + + ), + }, + ]; + + return ( + <> +
+ + {tabs.map((tab) => ( + setSelectedTabId(tab.id)} + key={tab.id} + > + {tab.name} + + ))} + +
+ {selectedTabId === DECISION_PATH_TABS.CHART && ( + <> + + + + + ), + }} + /> + + {analysisType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && ( + + )} + {analysisType === ANALYSIS_CONFIG_TYPE.REGRESSION && ( + + )} + + )} + {selectedTabId === DECISION_PATH_TABS.JSON && ( + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_regression.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_regression.tsx new file mode 100644 index 0000000000000..345269a944f02 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_regression.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useMemo } from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import d3 from 'd3'; +import { FeatureImportance, TopClasses } from '../../../../../common/types/feature_importance'; +import { useDecisionPathData, isDecisionPathData } from './use_classification_path_data'; +import { DecisionPathChart } from './decision_path_chart'; +import { MissingDecisionPathCallout } from './missing_decision_path_callout'; + +interface RegressionDecisionPathProps { + predictionFieldName?: string; + baseline?: number; + predictedValue?: number | undefined; + featureImportance: FeatureImportance[]; + topClasses?: TopClasses; +} + +export const RegressionDecisionPath: FC = ({ + baseline, + featureImportance, + predictedValue, + predictionFieldName, +}) => { + const { decisionPathData } = useDecisionPathData({ + baseline, + featureImportance, + predictedValue, + }); + const domain = useMemo(() => { + let maxDomain; + let minDomain; + // if decisionPathData has calculated cumulative path + if (decisionPathData && isDecisionPathData(decisionPathData)) { + const [min, max] = d3.extent(decisionPathData, (d: [string, number, number]) => d[2]); + maxDomain = max; + minDomain = min; + const buffer = Math.abs(maxDomain - minDomain) * 0.1; + maxDomain = + (typeof baseline === 'number' ? Math.max(maxDomain, baseline) : maxDomain) + buffer; + minDomain = + (typeof baseline === 'number' ? Math.min(minDomain, baseline) : minDomain) - buffer; + } + return { maxDomain, minDomain }; + }, [decisionPathData, baseline]); + + if (!decisionPathData) return ; + + return ( + <> + {baseline === undefined && ( + + } + color="warning" + iconType="alert" + /> + )} + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/missing_decision_path_callout.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/missing_decision_path_callout.tsx new file mode 100644 index 0000000000000..66eb2047b1314 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/missing_decision_path_callout.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const MissingDecisionPathCallout = () => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.tsx new file mode 100644 index 0000000000000..90216c4a58ffc --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.tsx @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FeatureImportance, TopClasses } from '../../../../../common/types/feature_importance'; +import { ExtendedFeatureImportance } from './decision_path_popover'; + +export type DecisionPathPlotData = Array<[string, number, number]>; + +interface UseDecisionPathDataParams { + featureImportance: FeatureImportance[]; + baseline?: number; + predictedValue?: string | number | undefined; + topClasses?: TopClasses; +} + +interface RegressionDecisionPathProps { + baseline?: number; + predictedValue?: number | undefined; + featureImportance: FeatureImportance[]; + topClasses?: TopClasses; +} +const FEATURE_NAME = 'feature_name'; +const FEATURE_IMPORTANCE = 'importance'; + +export const isDecisionPathData = (decisionPathData: any): boolean => { + return ( + Array.isArray(decisionPathData) && + decisionPathData.length > 0 && + decisionPathData[0].length === 3 + ); +}; + +// cast to 'True' | 'False' | value to match Eui display +export const getStringBasedClassName = (v: string | boolean | undefined | number): string => { + if (v === undefined) { + return ''; + } + if (typeof v === 'boolean') { + return v ? 'True' : 'False'; + } + if (typeof v === 'number') { + return v.toString(); + } + return v; +}; + +export const useDecisionPathData = ({ + baseline, + featureImportance, + predictedValue, +}: UseDecisionPathDataParams): { decisionPathData: DecisionPathPlotData | undefined } => { + const decisionPathData = useMemo(() => { + return baseline + ? buildRegressionDecisionPathData({ + baseline, + featureImportance, + predictedValue: predictedValue as number | undefined, + }) + : buildClassificationDecisionPathData({ + featureImportance, + currentClass: predictedValue as string | undefined, + }); + }, [baseline, featureImportance, predictedValue]); + + return { decisionPathData }; +}; + +export const buildDecisionPathData = (featureImportance: ExtendedFeatureImportance[]) => { + const finalResult: DecisionPathPlotData = featureImportance + // sort so absolute importance so it goes from bottom (baseline) to top + .sort( + (a: ExtendedFeatureImportance, b: ExtendedFeatureImportance) => + b.absImportance! - a.absImportance! + ) + .map((d) => [d[FEATURE_NAME] as string, d[FEATURE_IMPORTANCE] as number, NaN]); + + // start at the baseline and end at predicted value + // for regression, cumulativeSum should add up to baseline + let cumulativeSum = 0; + for (let i = featureImportance.length - 1; i >= 0; i--) { + cumulativeSum += finalResult[i][1]; + finalResult[i][2] = cumulativeSum; + } + return finalResult; +}; +export const buildRegressionDecisionPathData = ({ + baseline, + featureImportance, + predictedValue, +}: RegressionDecisionPathProps): DecisionPathPlotData | undefined => { + let mappedFeatureImportance: ExtendedFeatureImportance[] = featureImportance; + mappedFeatureImportance = mappedFeatureImportance.map((d) => ({ + ...d, + absImportance: Math.abs(d[FEATURE_IMPORTANCE] as number), + })); + + if (baseline && predictedValue !== undefined && Number.isFinite(predictedValue)) { + // get the adjusted importance needed for when # of fields included in c++ analysis != max allowed + // if num fields included = num features allowed exactly, adjustedImportance should be 0 + const adjustedImportance = + predictedValue - + mappedFeatureImportance.reduce( + (accumulator, currentValue) => accumulator + currentValue.importance!, + 0 + ) - + baseline; + + mappedFeatureImportance.push({ + [FEATURE_NAME]: i18n.translate( + 'xpack.ml.dataframe.analytics.decisionPathFeatureBaselineTitle', + { + defaultMessage: 'baseline', + } + ), + [FEATURE_IMPORTANCE]: baseline, + absImportance: -1, + }); + + // if the difference is small enough then no need to plot the residual feature importance + if (Math.abs(adjustedImportance) > 1e-5) { + mappedFeatureImportance.push({ + [FEATURE_NAME]: i18n.translate( + 'xpack.ml.dataframe.analytics.decisionPathFeatureOtherTitle', + { + defaultMessage: 'other', + } + ), + [FEATURE_IMPORTANCE]: adjustedImportance, + absImportance: 0, // arbitrary importance so this will be of higher importance than baseline + }); + } + } + const filteredFeatureImportance = mappedFeatureImportance.filter( + (f) => f !== undefined + ) as ExtendedFeatureImportance[]; + + return buildDecisionPathData(filteredFeatureImportance); +}; + +export const buildClassificationDecisionPathData = ({ + featureImportance, + currentClass, +}: { + featureImportance: FeatureImportance[]; + currentClass: string | undefined; +}): DecisionPathPlotData | undefined => { + if (currentClass === undefined) return []; + const mappedFeatureImportance: Array< + ExtendedFeatureImportance | undefined + > = featureImportance.map((feature) => { + const classFeatureImportance = Array.isArray(feature.classes) + ? feature.classes.find((c) => getStringBasedClassName(c.class_name) === currentClass) + : feature; + if (classFeatureImportance && typeof classFeatureImportance[FEATURE_IMPORTANCE] === 'number') { + return { + [FEATURE_NAME]: feature[FEATURE_NAME], + [FEATURE_IMPORTANCE]: classFeatureImportance[FEATURE_IMPORTANCE], + absImportance: Math.abs(classFeatureImportance[FEATURE_IMPORTANCE] as number), + }; + } + return undefined; + }); + const filteredFeatureImportance = mappedFeatureImportance.filter( + (f) => f !== undefined + ) as ExtendedFeatureImportance[]; + + return buildDecisionPathData(filteredFeatureImportance); +}; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/types.ts b/x-pack/plugins/ml/public/application/components/data_grid/types.ts index 756f74c8f9302..f9ee8c37fabf7 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/types.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/types.ts @@ -74,6 +74,9 @@ export interface UseIndexDataReturnType | 'tableItems' | 'toggleChartVisibility' | 'visibleColumns' + | 'baseline' + | 'predictionFieldName' + | 'resultsField' > { renderCellValue: RenderCellValue; } @@ -105,4 +108,7 @@ export interface UseDataGridReturnType { tableItems: DataGridItem[]; toggleChartVisibility: () => void; visibleColumns: ColumnId[]; + baseline?: number; + predictionFieldName?: string; + resultsField?: string; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 8ad861e616b7a..97098ea9e75c6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -15,18 +15,19 @@ import { SavedSearchQuery } from '../../contexts/ml'; import { AnalysisConfig, ClassificationAnalysis, - OutlierAnalysis, RegressionAnalysis, + ANALYSIS_CONFIG_TYPE, } from '../../../../common/types/data_frame_analytics'; - +import { + isOutlierAnalysis, + isRegressionAnalysis, + isClassificationAnalysis, + getPredictionFieldName, + getDependentVar, + getPredictedFieldName, +} from '../../../../common/util/analytics_utils'; export type IndexPattern = string; -export enum ANALYSIS_CONFIG_TYPE { - OUTLIER_DETECTION = 'outlier_detection', - REGRESSION = 'regression', - CLASSIFICATION = 'classification', -} - export enum ANALYSIS_ADVANCED_FIELDS { ETA = 'eta', FEATURE_BAG_FRACTION = 'feature_bag_fraction', @@ -156,23 +157,6 @@ export const getAnalysisType = (analysis: AnalysisConfig): string => { return 'unknown'; }; -export const getDependentVar = ( - analysis: AnalysisConfig -): - | RegressionAnalysis['regression']['dependent_variable'] - | ClassificationAnalysis['classification']['dependent_variable'] => { - let depVar = ''; - - if (isRegressionAnalysis(analysis)) { - depVar = analysis.regression.dependent_variable; - } - - if (isClassificationAnalysis(analysis)) { - depVar = analysis.classification.dependent_variable; - } - return depVar; -}; - export const getTrainingPercent = ( analysis: AnalysisConfig ): @@ -190,24 +174,6 @@ export const getTrainingPercent = ( return trainingPercent; }; -export const getPredictionFieldName = ( - analysis: AnalysisConfig -): - | RegressionAnalysis['regression']['prediction_field_name'] - | ClassificationAnalysis['classification']['prediction_field_name'] => { - // If undefined will be defaulted to dependent_variable when config is created - let predictionFieldName; - if (isRegressionAnalysis(analysis) && analysis.regression.prediction_field_name !== undefined) { - predictionFieldName = analysis.regression.prediction_field_name; - } else if ( - isClassificationAnalysis(analysis) && - analysis.classification.prediction_field_name !== undefined - ) { - predictionFieldName = analysis.classification.prediction_field_name; - } - return predictionFieldName; -}; - export const getNumTopClasses = ( analysis: AnalysisConfig ): ClassificationAnalysis['classification']['num_top_classes'] => { @@ -238,35 +204,6 @@ export const getNumTopFeatureImportanceValues = ( return numTopFeatureImportanceValues; }; -export const getPredictedFieldName = ( - resultsField: string, - analysis: AnalysisConfig, - forSort?: boolean -) => { - // default is 'ml' - const predictionFieldName = getPredictionFieldName(analysis); - const defaultPredictionField = `${getDependentVar(analysis)}_prediction`; - const predictedField = `${resultsField}.${ - predictionFieldName ? predictionFieldName : defaultPredictionField - }`; - return predictedField; -}; - -export const isOutlierAnalysis = (arg: any): arg is OutlierAnalysis => { - const keys = Object.keys(arg); - return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION; -}; - -export const isRegressionAnalysis = (arg: any): arg is RegressionAnalysis => { - const keys = Object.keys(arg); - return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.REGRESSION; -}; - -export const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => { - const keys = Object.keys(arg); - return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; -}; - export const isResultsSearchBoolQuery = (arg: any): arg is ResultsSearchBoolQuery => { if (arg === undefined) return false; const keys = Object.keys(arg); @@ -607,3 +544,13 @@ export const loadDocsCount = async ({ }; } }; + +export { + isOutlierAnalysis, + isRegressionAnalysis, + isClassificationAnalysis, + getPredictionFieldName, + ANALYSIS_CONFIG_TYPE, + getDependentVar, + getPredictedFieldName, +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/constants.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/constants.ts index 2f14dfdfdfca3..c2295a92af89c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/constants.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/constants.ts @@ -3,8 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -export const DEFAULT_RESULTS_FIELD = 'ml'; export const FEATURE_IMPORTANCE = 'feature_importance'; export const FEATURE_INFLUENCE = 'feature_influence'; export const TOP_CLASSES = 'top_classes'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts index 847aefefbc6c8..f9c9bf26a9d16 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -4,17 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { getNumTopClasses, getNumTopFeatureImportanceValues } from './analytics'; +import { Field } from '../../../../common/types/fields'; import { - getNumTopClasses, - getNumTopFeatureImportanceValues, getPredictedFieldName, getDependentVar, getPredictionFieldName, isClassificationAnalysis, isOutlierAnalysis, isRegressionAnalysis, -} from './analytics'; -import { Field } from '../../../../common/types/fields'; +} from '../../../../common/util/analytics_utils'; import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; import { newJobCapsService } from '../../services/new_job_capabilities_service'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx index ccac9a697210b..2e3a5d89367ce 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx @@ -9,7 +9,6 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { ExplorationPageWrapper } from '../exploration_page_wrapper'; - import { EvaluatePanel } from './evaluate_panel'; interface Props { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx index 34ff36c59fa6c..84b44ef0d349f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx @@ -51,7 +51,6 @@ export const ExplorationPageWrapper: FC = ({ jobId, title, EvaluatePanel /> ); } - return ( <> {isLoadingJobConfig === true && jobConfig === undefined && } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx index 8395a11bd6fda..eea579ef1d064 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx @@ -28,6 +28,8 @@ import { INDEX_STATUS, SEARCH_SIZE, defaultSearchQuery, + getAnalysisType, + ANALYSIS_CONFIG_TYPE, } from '../../../../common'; import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/use_columns'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; @@ -36,6 +38,7 @@ import { ExplorationQueryBar } from '../exploration_query_bar'; import { IndexPatternPrompt } from '../index_pattern_prompt'; import { useExplorationResults } from './use_exploration_results'; +import { useMlKibana } from '../../../../../contexts/kibana'; const showingDocs = i18n.translate( 'xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText', @@ -70,18 +73,27 @@ export const ExplorationResultsTable: FC = React.memo( setEvaluateSearchQuery, title, }) => { + const { + services: { + mlServices: { mlApiServices }, + }, + } = useMlKibana(); const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); useEffect(() => { setEvaluateSearchQuery(searchQuery); }, [JSON.stringify(searchQuery)]); + const analysisType = getAnalysisType(jobConfig.analysis); + const classificationData = useExplorationResults( indexPattern, jobConfig, searchQuery, - getToastNotifications() + getToastNotifications(), + mlApiServices ); + const docFieldsCount = classificationData.columnsWithCharts.length; const { columnsWithCharts, @@ -94,7 +106,6 @@ export const ExplorationResultsTable: FC = React.memo( if (jobConfig === undefined || classificationData === undefined) { return null; } - // if it's a searchBar syntax error leave the table visible so they can try again if (status === INDEX_STATUS.ERROR && !errorMessage.includes('failed to create query')) { return ( @@ -184,6 +195,7 @@ export const ExplorationResultsTable: FC = React.memo( {...classificationData} dataTestSubj="mlExplorationDataGrid" toastNotifications={getToastNotifications()} + analysisType={(analysisType as unknown) as ANALYSIS_CONFIG_TYPE} /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts index 8d53214d23d47..a56345017258e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; import { CoreSetup } from 'src/core/public'; +import { i18n } from '@kbn/i18n'; +import { MlApiServices } from '../../../../../services/ml_api_service'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; import { DataLoader } from '../../../../../datavisualizer/index_based/data_loader'; @@ -23,21 +25,26 @@ import { UseIndexDataReturnType, } from '../../../../../components/data_grid'; import { SavedSearchQuery } from '../../../../../contexts/ml'; - import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common'; import { - DEFAULT_RESULTS_FIELD, - FEATURE_IMPORTANCE, - TOP_CLASSES, -} from '../../../../common/constants'; + getPredictionFieldName, + getDefaultPredictionFieldName, +} from '../../../../../../../common/util/analytics_utils'; +import { FEATURE_IMPORTANCE, TOP_CLASSES } from '../../../../common/constants'; +import { DEFAULT_RESULTS_FIELD } from '../../../../../../../common/constants/data_frame_analytics'; import { sortExplorationResultsFields, ML__ID_COPY } from '../../../../common/fields'; +import { isRegressionAnalysis } from '../../../../common/analytics'; +import { extractErrorMessage } from '../../../../../../../common/util/errors'; export const useExplorationResults = ( indexPattern: IndexPattern | undefined, jobConfig: DataFrameAnalyticsConfig | undefined, searchQuery: SavedSearchQuery, - toastNotifications: CoreSetup['notifications']['toasts'] + toastNotifications: CoreSetup['notifications']['toasts'], + mlApiServices: MlApiServices ): UseIndexDataReturnType => { + const [baseline, setBaseLine] = useState(); + const needsDestIndexFields = indexPattern !== undefined && indexPattern.title === jobConfig?.source.index[0]; @@ -52,7 +59,6 @@ export const useExplorationResults = ( ) ); } - const dataGrid = useDataGrid( columns, 25, @@ -107,16 +113,60 @@ export const useExplorationResults = ( jobConfig?.dest.index, JSON.stringify([searchQuery, dataGrid.visibleColumns]), ]); + const predictionFieldName = useMemo(() => { + if (jobConfig) { + return ( + getPredictionFieldName(jobConfig.analysis) ?? + getDefaultPredictionFieldName(jobConfig.analysis) + ); + } + return undefined; + }, [jobConfig]); + + const getAnalyticsBaseline = useCallback(async () => { + try { + if ( + jobConfig !== undefined && + jobConfig.analysis !== undefined && + isRegressionAnalysis(jobConfig.analysis) + ) { + const result = await mlApiServices.dataFrameAnalytics.getAnalyticsBaseline(jobConfig.id); + if (result?.baseline) { + setBaseLine(result.baseline); + } + } + } catch (e) { + const error = extractErrorMessage(e); + + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.ml.dataframe.analytics.explorationResults.baselineErrorMessageToast', + { + defaultMessage: 'An error occurred getting feature importance baseline', + } + ), + text: error, + }); + } + }, [mlApiServices, jobConfig]); + + useEffect(() => { + getAnalyticsBaseline(); + }, [jobConfig]); + const resultsField = jobConfig?.dest.results_field ?? DEFAULT_RESULTS_FIELD; const renderCellValue = useRenderCellValue( indexPattern, dataGrid.pagination, dataGrid.tableItems, - jobConfig?.dest.results_field ?? DEFAULT_RESULTS_FIELD + resultsField ); return { ...dataGrid, renderCellValue, + baseline, + predictionFieldName, + resultsField, }; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts index 24649ae5f1e71..151e5ea4e6feb 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts @@ -29,7 +29,8 @@ import { SavedSearchQuery } from '../../../../../contexts/ml'; import { getToastNotifications } from '../../../../../util/dependency_cache'; import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common'; -import { DEFAULT_RESULTS_FIELD, FEATURE_INFLUENCE } from '../../../../common/constants'; +import { FEATURE_INFLUENCE } from '../../../../common/constants'; +import { DEFAULT_RESULTS_FIELD } from '../../../../../../../common/constants/data_frame_analytics'; import { sortExplorationResultsFields, ML__ID_COPY } from '../../../../common/fields'; import { getFeatureCount, getOutlierScoreFieldName } from './common'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_action_name.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_action_name.tsx index 60c699ba0d370..ce24892c9de45 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_action_name.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_action_name.tsx @@ -12,7 +12,7 @@ import { IIndexPattern } from 'src/plugins/data/common'; import { DeepReadonly } from '../../../../../../../common/types/common'; import { DataFrameAnalyticsConfig, isOutlierAnalysis } from '../../../../common'; import { isClassificationAnalysis, isRegressionAnalysis } from '../../../../common/analytics'; -import { DEFAULT_RESULTS_FIELD } from '../../../../common/constants'; +import { DEFAULT_RESULTS_FIELD } from '../../../../../../../common/constants/data_frame_analytics'; import { useMlKibana, useNavigateToPath } from '../../../../../contexts/kibana'; import { DEFAULT_NUM_TOP_FEATURE_IMPORTANCE_VALUES } from '../../hooks/use_create_analytics_form'; import { State } from '../../hooks/use_create_analytics_form/state'; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index 7de39d91047ef..434200d0383f5 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -135,4 +135,10 @@ export const dataFrameAnalytics = { method: 'GET', }); }, + getAnalyticsBaseline(analyticsId: string) { + return http({ + path: `${basePath()}/data_frame/analytics/${analyticsId}/baseline`, + method: 'POST', + }); + }, }; diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/feature_importance.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/feature_importance.ts new file mode 100644 index 0000000000000..94f54a5654873 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/feature_importance.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IScopedClusterClient } from 'kibana/server'; +import { + getDefaultPredictionFieldName, + getPredictionFieldName, + isRegressionAnalysis, +} from '../../../common/util/analytics_utils'; +import { DEFAULT_RESULTS_FIELD } from '../../../common/constants/data_frame_analytics'; +// Obtains data for the data frame analytics feature importance functionalities +// such as baseline, decision paths, or importance summary. +export function analyticsFeatureImportanceProvider({ + asInternalUser, + asCurrentUser, +}: IScopedClusterClient) { + async function getRegressionAnalyticsBaseline(analyticsId: string): Promise { + const { body } = await asInternalUser.ml.getDataFrameAnalytics({ + id: analyticsId, + }); + const jobConfig = body.data_frame_analytics[0]; + if (!isRegressionAnalysis) return undefined; + const destinationIndex = jobConfig.dest.index; + const predictionFieldName = getPredictionFieldName(jobConfig.analysis); + const mlResultsField = jobConfig.dest?.results_field ?? DEFAULT_RESULTS_FIELD; + const predictedField = `${mlResultsField}.${ + predictionFieldName ? predictionFieldName : getDefaultPredictionFieldName(jobConfig.analysis) + }`; + const isTrainingField = `${mlResultsField}.is_training`; + + const params = { + index: destinationIndex, + size: 0, + body: { + query: { + bool: { + filter: [ + { + term: { + [isTrainingField]: true, + }, + }, + ], + }, + }, + aggs: { + featureImportanceBaseline: { + avg: { + field: predictedField, + }, + }, + }, + }, + }; + let baseline; + const { body: aggregationResult } = await asCurrentUser.search(params); + if (aggregationResult) { + baseline = aggregationResult.aggregations.featureImportanceBaseline.value; + } + return baseline; + } + + return { + getRegressionAnalyticsBaseline, + }; +} diff --git a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts index dea4803e8275e..7606420eacefc 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -20,6 +20,7 @@ import { import { IndexPatternHandler } from '../models/data_frame_analytics/index_patterns'; import { DeleteDataFrameAnalyticsWithIndexStatus } from '../../common/types/data_frame_analytics'; import { getAuthorizationHeader } from '../lib/request_authorization'; +import { analyticsFeatureImportanceProvider } from '../models/data_frame_analytics/feature_importance'; function getIndexPatternId(context: RequestHandlerContext, patternName: string) { const iph = new IndexPatternHandler(context.core.savedObjects.client); @@ -545,4 +546,38 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat } }) ); + + /** + * @apiGroup DataFrameAnalytics + * + * @api {get} /api/ml/data_frame/analytics/baseline Get analytics's feature importance baseline + * @apiName GetDataFrameAnalyticsBaseline + * @apiDescription Returns the baseline for data frame analytics job. + * + * @apiSchema (params) analyticsIdSchema + */ + router.post( + { + path: '/api/ml/data_frame/analytics/{analyticsId}/baseline', + validate: { + params: analyticsIdSchema, + }, + options: { + tags: ['access:ml:canGetDataFrameAnalytics'], + }, + }, + mlLicense.fullLicenseAPIGuard(async ({ client, request, response }) => { + try { + const { analyticsId } = request.params; + const { getRegressionAnalyticsBaseline } = analyticsFeatureImportanceProvider(client); + const baseline = await getRegressionAnalyticsBaseline(analyticsId); + + return response.ok({ + body: { baseline }, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); } diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts index ffa1d9fd46c75..e01e065867ac7 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts @@ -10,25 +10,9 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { MlCommonUI } from './common_ui'; import { MlApi } from './api'; import { - ClassificationAnalysis, - RegressionAnalysis, -} from '../../../../plugins/ml/common/types/data_frame_analytics'; - -enum ANALYSIS_CONFIG_TYPE { - OUTLIER_DETECTION = 'outlier_detection', - REGRESSION = 'regression', - CLASSIFICATION = 'classification', -} - -const isRegressionAnalysis = (arg: any): arg is RegressionAnalysis => { - const keys = Object.keys(arg); - return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.REGRESSION; -}; - -const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => { - const keys = Object.keys(arg); - return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; -}; + isRegressionAnalysis, + isClassificationAnalysis, +} from '../../../../plugins/ml/common/util/analytics_utils'; export function MachineLearningDataFrameAnalyticsCreationProvider( { getService }: FtrProviderContext, From a0defb81963a134b7d7b3e3327149de1a26a5a02 Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 9 Sep 2020 10:01:28 -0700 Subject: [PATCH 09/25] [Enterprise Search] Update config data endpoint to v2 (#76970) * Update our internal config/app data to v2 specs - Update endpoint to v2 - Update data accordingly to new API structures - Update types accordingly * Fix failing type check for other endpoints that use IAccount * Update role type casing - ent search was fixed from camel to snake --- .../common/__mocks__/initial_app_data.ts | 20 +++-- .../common/types/app_search.ts | 7 ++ .../enterprise_search/common/types/index.ts | 15 +++- .../common/types/workplace_search.ts | 12 ++- .../lib/enterprise_search_config_api.test.ts | 88 ++++++++++++------- .../lib/enterprise_search_config_api.ts | 68 ++++++++------ 6 files changed, 139 insertions(+), 71 deletions(-) diff --git a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts index 2d31be65dd30e..4533383ebd80e 100644 --- a/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts +++ b/x-pack/plugins/enterprise_search/common/__mocks__/initial_app_data.ts @@ -7,9 +7,20 @@ export const DEFAULT_INITIAL_APP_DATA = { readOnlyMode: false, ilmEnabled: true, + isFederatedAuth: false, configuredLimits: { - maxDocumentByteSize: 102400, - maxEnginesPerMetaEngine: 15, + appSearch: { + engine: { + maxDocumentByteSize: 102400, + maxEnginesPerMetaEngine: 15, + }, + }, + workplaceSearch: { + customApiSource: { + maxDocumentByteSize: 102400, + totalFields: 64, + }, + }, }, appSearch: { accountId: 'some-id-string', @@ -29,17 +40,16 @@ export const DEFAULT_INITIAL_APP_DATA = { }, }, workplaceSearch: { - canCreateInvitations: true, - isFederatedAuth: false, organization: { name: 'ACME Donuts', defaultOrgName: 'My Organization', }, - fpAccount: { + account: { id: 'some-id-string', groups: ['Default', 'Cats'], isAdmin: true, canCreatePersonalSources: true, + canCreateInvitations: true, isCurated: false, viewedOnboardingPage: true, }, diff --git a/x-pack/plugins/enterprise_search/common/types/app_search.ts b/x-pack/plugins/enterprise_search/common/types/app_search.ts index 5d6ec079e66e0..72259ecd2343d 100644 --- a/x-pack/plugins/enterprise_search/common/types/app_search.ts +++ b/x-pack/plugins/enterprise_search/common/types/app_search.ts @@ -23,3 +23,10 @@ export interface IRole { availableRoleTypes: string[]; }; } + +export interface IConfiguredLimits { + engine: { + maxDocumentByteSize: number; + maxEnginesPerMetaEngine: number; + }; +} diff --git a/x-pack/plugins/enterprise_search/common/types/index.ts b/x-pack/plugins/enterprise_search/common/types/index.ts index 008afb234a376..a41a42da477ee 100644 --- a/x-pack/plugins/enterprise_search/common/types/index.ts +++ b/x-pack/plugins/enterprise_search/common/types/index.ts @@ -4,18 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IAccount as IAppSearchAccount } from './app_search'; -import { IWorkplaceSearchInitialData } from './workplace_search'; +import { + IAccount as IAppSearchAccount, + IConfiguredLimits as IAppSearchConfiguredLimits, +} from './app_search'; +import { + IWorkplaceSearchInitialData, + IConfiguredLimits as IWorkplaceSearchConfiguredLimits, +} from './workplace_search'; export interface IInitialAppData { readOnlyMode?: boolean; ilmEnabled?: boolean; + isFederatedAuth?: boolean; configuredLimits?: IConfiguredLimits; appSearch?: IAppSearchAccount; workplaceSearch?: IWorkplaceSearchInitialData; } export interface IConfiguredLimits { - maxDocumentByteSize: number; - maxEnginesPerMetaEngine: number; + appSearch: IAppSearchConfiguredLimits; + workplaceSearch: IWorkplaceSearchConfiguredLimits; } diff --git a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts index bc4e39b0788d9..6c82206706b32 100644 --- a/x-pack/plugins/enterprise_search/common/types/workplace_search.ts +++ b/x-pack/plugins/enterprise_search/common/types/workplace_search.ts @@ -10,6 +10,7 @@ export interface IAccount { isAdmin: boolean; isCurated: boolean; canCreatePersonalSources: boolean; + canCreateInvitations?: boolean; viewedOnboardingPage: boolean; } @@ -19,8 +20,13 @@ export interface IOrganization { } export interface IWorkplaceSearchInitialData { - canCreateInvitations: boolean; - isFederatedAuth: boolean; organization: IOrganization; - fpAccount: IAccount; + account: IAccount; +} + +export interface IConfiguredLimits { + customApiSource: { + maxDocumentByteSize: number; + totalFields: number; + }; } 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 323f79e63bc6f..8e3ae2cfbeb86 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 @@ -38,51 +38,63 @@ describe('callEnterpriseSearchConfigAPI', () => { external_url: 'http://some.vanity.url/', read_only_mode: false, ilm_enabled: true, + is_federated_auth: false, configured_limits: { - max_document_byte_size: 102400, - max_engines_per_meta_engine: 15, + app_search: { + engine: { + document_size_in_bytes: 102400, + source_engines_per_meta_engine: 15, + }, + }, + workplace_search: { + custom_api_source: { + document_size_in_bytes: 102400, + total_fields: 64, + }, + }, + }, + }, + current_user: { + name: 'someuser', + access: { + app_search: true, + workplace_search: false, }, app_search: { - account_id: 'some-id-string', - onboarding_complete: true, + account: { + id: 'some-id-string', + onboarding_complete: true, + }, + role: { + id: 'account_id:somestring|user_oid:somestring', + role_type: 'owner', + ability: { + access_all_engines: true, + destroy: ['session'], + manage: ['account_credentials', 'account_engines'], // etc + edit: ['LocoMoco::Account'], // etc + view: ['Engine'], // etc + credential_types: ['admin', 'private', 'search'], + available_role_types: ['owner', 'admin'], + }, + }, }, workplace_search: { - can_create_invitations: true, - is_federated_auth: false, organization: { name: 'ACME Donuts', default_org_name: 'My Organization', }, - fp_account: { + account: { id: 'some-id-string', groups: ['Default', 'Cats'], is_admin: true, can_create_personal_sources: true, + can_create_invitations: true, is_curated: false, viewed_onboarding_page: true, }, }, }, - current_user: { - name: 'someuser', - access: { - app_search: true, - workplace_search: false, - }, - app_search_role: { - id: 'account_id:somestring|user_oid:somestring', - role_type: 'owner', - ability: { - access_all_engines: true, - destroy: ['session'], - manage: ['account_credentials', 'account_engines'], // etc - edit: ['LocoMoco::Account'], // etc - view: ['Engine'], // etc - credential_types: ['admin', 'private', 'search'], - available_role_types: ['owner', 'admin'], - }, - }, - }, }; beforeEach(() => { @@ -91,7 +103,7 @@ describe('callEnterpriseSearchConfigAPI', () => { it('calls the config API endpoint', async () => { fetchMock.mockImplementationOnce((url: string) => { - expect(url).toEqual('http://localhost:3002/api/ent/v1/internal/client_config'); + expect(url).toEqual('http://localhost:3002/api/ent/v2/internal/client_config'); return Promise.resolve(new Response(JSON.stringify(mockResponse))); }); @@ -116,9 +128,20 @@ describe('callEnterpriseSearchConfigAPI', () => { publicUrl: undefined, readOnlyMode: false, ilmEnabled: false, + isFederatedAuth: false, configuredLimits: { - maxDocumentByteSize: undefined, - maxEnginesPerMetaEngine: undefined, + appSearch: { + engine: { + maxDocumentByteSize: undefined, + maxEnginesPerMetaEngine: undefined, + }, + }, + workplaceSearch: { + customApiSource: { + maxDocumentByteSize: undefined, + totalFields: undefined, + }, + }, }, appSearch: { accountId: undefined, @@ -138,17 +161,16 @@ describe('callEnterpriseSearchConfigAPI', () => { }, }, workplaceSearch: { - canCreateInvitations: false, - isFederatedAuth: false, organization: { name: undefined, defaultOrgName: undefined, }, - fpAccount: { + account: { id: undefined, groups: [], isAdmin: false, canCreatePersonalSources: false, + canCreateInvitations: false, isCurated: false, viewedOnboardingPage: false, }, 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 c9cbec15169d9..10a75e59cb249 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 @@ -29,7 +29,7 @@ interface IReturn extends IInitialAppData { * useful various settings (e.g. product access, external URL) * needed by the Kibana plugin at the setup stage */ -const ENDPOINT = '/api/ent/v1/internal/client_config'; +const ENDPOINT = '/api/ent/v2/internal/client_config'; export const callEnterpriseSearchConfigAPI = async ({ config, @@ -67,44 +67,60 @@ export const callEnterpriseSearchConfigAPI = async ({ publicUrl: stripTrailingSlash(data?.settings?.external_url), readOnlyMode: !!data?.settings?.read_only_mode, ilmEnabled: !!data?.settings?.ilm_enabled, + isFederatedAuth: !!data?.settings?.is_federated_auth, // i.e., not standard auth configuredLimits: { - maxDocumentByteSize: data?.settings?.configured_limits?.max_document_byte_size, - maxEnginesPerMetaEngine: data?.settings?.configured_limits?.max_engines_per_meta_engine, + appSearch: { + engine: { + maxDocumentByteSize: + data?.settings?.configured_limits?.app_search?.engine?.document_size_in_bytes, + maxEnginesPerMetaEngine: + data?.settings?.configured_limits?.app_search?.engine?.source_engines_per_meta_engine, + }, + }, + workplaceSearch: { + customApiSource: { + maxDocumentByteSize: + data?.settings?.configured_limits?.workplace_search?.custom_api_source + ?.document_size_in_bytes, + totalFields: + data?.settings?.configured_limits?.workplace_search?.custom_api_source?.total_fields, + }, + }, }, appSearch: { - accountId: data?.settings?.app_search?.account_id, - onBoardingComplete: !!data?.settings?.app_search?.onboarding_complete, + accountId: data?.current_user?.app_search?.account?.id, + onBoardingComplete: !!data?.current_user?.app_search?.account?.onboarding_complete, role: { - id: data?.current_user?.app_search_role?.id, - roleType: data?.current_user?.app_search_role?.role_type, + id: data?.current_user?.app_search?.role?.id, + roleType: data?.current_user?.app_search?.role?.role_type, ability: { - accessAllEngines: !!data?.current_user?.app_search_role?.ability?.access_all_engines, - destroy: data?.current_user?.app_search_role?.ability?.destroy || [], - manage: data?.current_user?.app_search_role?.ability?.manage || [], - edit: data?.current_user?.app_search_role?.ability?.edit || [], - view: data?.current_user?.app_search_role?.ability?.view || [], - credentialTypes: data?.current_user?.app_search_role?.ability?.credential_types || [], + accessAllEngines: !!data?.current_user?.app_search?.role?.ability?.access_all_engines, + destroy: data?.current_user?.app_search?.role?.ability?.destroy || [], + manage: data?.current_user?.app_search?.role?.ability?.manage || [], + edit: data?.current_user?.app_search?.role?.ability?.edit || [], + view: data?.current_user?.app_search?.role?.ability?.view || [], + credentialTypes: data?.current_user?.app_search?.role?.ability?.credential_types || [], availableRoleTypes: - data?.current_user?.app_search_role?.ability?.available_role_types || [], + data?.current_user?.app_search?.role?.ability?.available_role_types || [], }, }, }, workplaceSearch: { - canCreateInvitations: !!data?.settings?.workplace_search?.can_create_invitations, - isFederatedAuth: !!data?.settings?.workplace_search?.is_federated_auth, organization: { - name: data?.settings?.workplace_search?.organization?.name, - defaultOrgName: data?.settings?.workplace_search?.organization?.default_org_name, + name: data?.current_user?.workplace_search?.organization?.name, + defaultOrgName: data?.current_user?.workplace_search?.organization?.default_org_name, }, - fpAccount: { - id: data?.settings?.workplace_search?.fp_account.id, - groups: data?.settings?.workplace_search?.fp_account.groups || [], - isAdmin: !!data?.settings?.workplace_search?.fp_account?.is_admin, - canCreatePersonalSources: !!data?.settings?.workplace_search?.fp_account + account: { + id: data?.current_user?.workplace_search?.account?.id, + groups: data?.current_user?.workplace_search?.account?.groups || [], + isAdmin: !!data?.current_user?.workplace_search?.account?.is_admin, + canCreatePersonalSources: !!data?.current_user?.workplace_search?.account ?.can_create_personal_sources, - isCurated: !!data?.settings?.workplace_search?.fp_account.is_curated, - viewedOnboardingPage: !!data?.settings?.workplace_search?.fp_account - .viewed_onboarding_page, + canCreateInvitations: !!data?.current_user?.workplace_search?.account + ?.can_create_invitations, + isCurated: !!data?.current_user?.workplace_search?.account?.is_curated, + viewedOnboardingPage: !!data?.current_user?.workplace_search?.account + ?.viewed_onboarding_page, }, }, }; From 7fc4bb3799542b5813933af989cba678521f1275 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 9 Sep 2020 19:37:08 +0200 Subject: [PATCH 10/25] IndexMigrator: fix non blocking migration wrapper promise rejection (#77018) * fix transformNonBlocking * add test for indexMigrator --- .../migrations/core/index_migrator.test.ts | 24 +++++++++++++++++++ .../migrations/core/migrate_raw_docs.test.ts | 14 +++++++++++ .../migrations/core/migrate_raw_docs.ts | 8 +++++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index df89137a1d798..13f771c16bc67 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -369,6 +369,30 @@ describe('IndexMigrator', () => { ], }); }); + + test('rejects when the migration function throws an error', async () => { + const { client } = testOpts; + const migrateDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => { + throw new Error('error migrating document'); + }); + + testOpts.documentMigrator = { + migrationVersion: { foo: '1.2.3' }, + migrate: migrateDoc, + }; + + withIndex(client, { + numOutOfDate: 1, + docs: [ + [{ _id: 'foo:1', _source: { type: 'foo', foo: { name: 'Bar' } } }], + [{ _id: 'foo:2', _source: { type: 'foo', foo: { name: 'Baz' } } }], + ], + }); + + await expect(new IndexMigrator(testOpts).migrate()).rejects.toThrowErrorMatchingInlineSnapshot( + `"error migrating document"` + ); + }); }); function withIndex( diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts index 4c9d2e870a7bb..83dc042d2b96b 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.test.ts @@ -90,4 +90,18 @@ describe('migrateRawDocs', () => { expect(logger.error).toBeCalledTimes(1); }); + + test('rejects when the transform function throws an error', async () => { + const transform = jest.fn((doc: any) => { + throw new Error('error during transform'); + }); + await expect( + migrateRawDocs( + new SavedObjectsSerializer(new SavedObjectTypeRegistry()), + transform, + [{ _id: 'a:b', _source: { type: 'a', a: { name: 'AAA' } } }], + createSavedObjectsMigrationLoggerMock() + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"error during transform"`); + }); }); diff --git a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts index 2bdf59d25dc74..5a5048d8ad88f 100644 --- a/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -78,10 +78,14 @@ function transformNonBlocking( ): (doc: SavedObjectUnsanitizedDoc) => Promise { // promises aren't enough to unblock the event loop return (doc: SavedObjectUnsanitizedDoc) => - new Promise((resolve) => { + new Promise((resolve, reject) => { // set immediate is though setImmediate(() => { - resolve(transform(doc)); + try { + resolve(transform(doc)); + } catch (e) { + reject(e); + } }); }); } From 7e84661471f95c9e84ea3844b724f730e80d4868 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 9 Sep 2020 11:44:58 -0600 Subject: [PATCH 11/25] [Maps] convert MetricsEditor to TS (#76727) * [Maps] convert MetricsEditor to TS * fix jest test Co-authored-by: Elastic Machine --- .../maps/public/components/_index.scss | 2 +- .../metrics_editor.test.tsx.snap} | 6 +- .../{ => metrics_editor}/_metric_editors.scss | 0 .../public/components/metrics_editor/index.ts | 7 ++ .../metric_editor.tsx} | 83 +++++++++++++------ .../metric_select.tsx} | 25 +++--- .../metrics_editor.test.tsx} | 7 +- .../metrics_editor.tsx} | 73 +++++++--------- .../resources/metrics_expression.test.js | 6 -- 9 files changed, 112 insertions(+), 97 deletions(-) rename x-pack/plugins/maps/public/components/{__snapshots__/metrics_editor.test.js.snap => metrics_editor/__snapshots__/metrics_editor.test.tsx.snap} (92%) rename x-pack/plugins/maps/public/components/{ => metrics_editor}/_metric_editors.scss (100%) create mode 100644 x-pack/plugins/maps/public/components/metrics_editor/index.ts rename x-pack/plugins/maps/public/components/{metric_editor.js => metrics_editor/metric_editor.tsx} (59%) rename x-pack/plugins/maps/public/components/{metric_select.js => metrics_editor/metric_select.tsx} (80%) rename x-pack/plugins/maps/public/components/{metrics_editor.test.js => metrics_editor/metrics_editor.test.tsx} (84%) rename x-pack/plugins/maps/public/components/{metrics_editor.js => metrics_editor/metrics_editor.tsx} (54%) diff --git a/x-pack/plugins/maps/public/components/_index.scss b/x-pack/plugins/maps/public/components/_index.scss index 76ce9f1bc79e3..726573ce4307d 100644 --- a/x-pack/plugins/maps/public/components/_index.scss +++ b/x-pack/plugins/maps/public/components/_index.scss @@ -1,4 +1,4 @@ @import 'action_select'; -@import 'metric_editors'; +@import 'metrics_editor/metric_editors'; @import './geometry_filter'; @import 'tooltip_selector/tooltip_selector'; diff --git a/x-pack/plugins/maps/public/components/__snapshots__/metrics_editor.test.js.snap b/x-pack/plugins/maps/public/components/metrics_editor/__snapshots__/metrics_editor.test.tsx.snap similarity index 92% rename from x-pack/plugins/maps/public/components/__snapshots__/metrics_editor.test.js.snap rename to x-pack/plugins/maps/public/components/metrics_editor/__snapshots__/metrics_editor.test.tsx.snap index 0d4f1f99e464c..bd58ded41e7f5 100644 --- a/x-pack/plugins/maps/public/components/__snapshots__/metrics_editor.test.js.snap +++ b/x-pack/plugins/maps/public/components/metrics_editor/__snapshots__/metrics_editor.test.tsx.snap @@ -16,8 +16,9 @@ exports[`should add default count metric when metrics is empty array 1`] = ` "type": "count", } } - metricsFilter={[Function]} onChange={[Function]} + onRemove={[Function]} + showRemoveButton={false} />
@@ -59,8 +60,9 @@ exports[`should render metrics editor 1`] = ` "type": "sum", } } - metricsFilter={[Function]} onChange={[Function]} + onRemove={[Function]} + showRemoveButton={false} /> diff --git a/x-pack/plugins/maps/public/components/_metric_editors.scss b/x-pack/plugins/maps/public/components/metrics_editor/_metric_editors.scss similarity index 100% rename from x-pack/plugins/maps/public/components/_metric_editors.scss rename to x-pack/plugins/maps/public/components/metrics_editor/_metric_editors.scss diff --git a/x-pack/plugins/maps/public/components/metrics_editor/index.ts b/x-pack/plugins/maps/public/components/metrics_editor/index.ts new file mode 100644 index 0000000000000..3c105c2d798ff --- /dev/null +++ b/x-pack/plugins/maps/public/components/metrics_editor/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { MetricsEditor } from './metrics_editor'; diff --git a/x-pack/plugins/maps/public/components/metric_editor.js b/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx similarity index 59% rename from x-pack/plugins/maps/public/components/metric_editor.js rename to x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx index 96b52d84653b2..543d144efdcc7 100644 --- a/x-pack/plugins/maps/public/components/metric_editor.js +++ b/x-pack/plugins/maps/public/components/metrics_editor/metric_editor.tsx @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; +import React, { ChangeEvent, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFieldText, EuiFormRow } from '@elastic/eui'; +import { EuiButtonEmpty, EuiComboBoxOptionOption, EuiFieldText, EuiFormRow } from '@elastic/eui'; -import { MetricSelect, METRIC_AGGREGATION_VALUES } from './metric_select'; -import { SingleFieldSelect } from './single_field_select'; -import { AGG_TYPE } from '../../common/constants'; -import { getTermsFields } from '../index_pattern_util'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { MetricSelect } from './metric_select'; +import { SingleFieldSelect } from '../single_field_select'; +import { AggDescriptor } from '../../../common/descriptor_types'; +import { AGG_TYPE } from '../../../common/constants'; +import { getTermsFields } from '../../index_pattern_util'; +import { IFieldType } from '../../../../../../src/plugins/data/public'; -function filterFieldsForAgg(fields, aggType) { +function filterFieldsForAgg(fields: IFieldType[], aggType: AGG_TYPE) { if (!fields) { return []; } @@ -34,8 +36,27 @@ function filterFieldsForAgg(fields, aggType) { }); } -export function MetricEditor({ fields, metricsFilter, metric, onChange, removeButton }) { - const onAggChange = (metricAggregationType) => { +interface Props { + metric: AggDescriptor; + fields: IFieldType[]; + onChange: (metric: AggDescriptor) => void; + onRemove: () => void; + metricsFilter?: (metricOption: EuiComboBoxOptionOption) => boolean; + showRemoveButton: boolean; +} + +export function MetricEditor({ + fields, + metricsFilter, + metric, + onChange, + showRemoveButton, + onRemove, +}: Props) { + const onAggChange = (metricAggregationType?: AGG_TYPE) => { + if (!metricAggregationType) { + return; + } const newMetricProps = { ...metric, type: metricAggregationType, @@ -54,13 +75,16 @@ export function MetricEditor({ fields, metricsFilter, metric, onChange, removeBu onChange(newMetricProps); }; - const onFieldChange = (fieldName) => { + const onFieldChange = (fieldName?: string) => { + if (!fieldName) { + return; + } onChange({ ...metric, field: fieldName, }); }; - const onLabelChange = (e) => { + const onLabelChange = (e: ChangeEvent) => { onChange({ ...metric, label: e.target.value, @@ -80,7 +104,7 @@ export function MetricEditor({ fields, metricsFilter, metric, onChange, removeBu placeholder={i18n.translate('xpack.maps.metricsEditor.selectFieldPlaceholder', { defaultMessage: 'Select field', })} - value={metric.field} + value={metric.field ? metric.field : null} onChange={onFieldChange} fields={filterFieldsForAgg(fields, metric.type)} isClearable={false} @@ -108,6 +132,28 @@ export function MetricEditor({ fields, metricsFilter, metric, onChange, removeBu ); } + let removeButton; + if (showRemoveButton) { + removeButton = ( +
+ + + +
+ ); + } + return ( ); } - -MetricEditor.propTypes = { - metric: PropTypes.shape({ - type: PropTypes.oneOf(METRIC_AGGREGATION_VALUES), - field: PropTypes.string, - label: PropTypes.string, - }), - fields: PropTypes.array, - onChange: PropTypes.func.isRequired, - metricsFilter: PropTypes.func, -}; diff --git a/x-pack/plugins/maps/public/components/metric_select.js b/x-pack/plugins/maps/public/components/metrics_editor/metric_select.tsx similarity index 80% rename from x-pack/plugins/maps/public/components/metric_select.js rename to x-pack/plugins/maps/public/components/metrics_editor/metric_select.tsx index 2ebfcf99dece6..197c5466fe0fd 100644 --- a/x-pack/plugins/maps/public/components/metric_select.js +++ b/x-pack/plugins/maps/public/components/metrics_editor/metric_select.tsx @@ -5,10 +5,9 @@ */ import React from 'react'; -import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; -import { EuiComboBox } from '@elastic/eui'; -import { AGG_TYPE } from '../../common/constants'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui'; +import { AGG_TYPE } from '../../../common/constants'; const AGG_OPTIONS = [ { @@ -55,17 +54,19 @@ const AGG_OPTIONS = [ }, ]; -export const METRIC_AGGREGATION_VALUES = AGG_OPTIONS.map(({ value }) => { - return value; -}); +type Props = Omit, 'onChange'> & { + value: AGG_TYPE; + onChange: (aggType: AGG_TYPE) => void; + metricsFilter?: (metricOption: EuiComboBoxOptionOption) => boolean; +}; -export function MetricSelect({ value, onChange, metricsFilter, ...rest }) { - function onAggChange(selectedOptions) { +export function MetricSelect({ value, onChange, metricsFilter, ...rest }: Props) { + function onAggChange(selectedOptions: Array>) { if (selectedOptions.length === 0) { return; } - const aggType = selectedOptions[0].value; + const aggType = selectedOptions[0].value!; onChange(aggType); } @@ -87,9 +88,3 @@ export function MetricSelect({ value, onChange, metricsFilter, ...rest }) { /> ); } - -MetricSelect.propTypes = { - metricsFilter: PropTypes.func, - value: PropTypes.oneOf(METRIC_AGGREGATION_VALUES), - onChange: PropTypes.func.isRequired, -}; diff --git a/x-pack/plugins/maps/public/components/metrics_editor.test.js b/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.test.tsx similarity index 84% rename from x-pack/plugins/maps/public/components/metrics_editor.test.js rename to x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.test.tsx index bcbeef29875ee..7ce7fbce2b066 100644 --- a/x-pack/plugins/maps/public/components/metrics_editor.test.js +++ b/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { MetricsEditor } from './metrics_editor'; -import { AGG_TYPE } from '../../common/constants'; +import { AGG_TYPE } from '../../../common/constants'; const defaultProps = { metrics: [ @@ -19,15 +19,14 @@ const defaultProps = { fields: [], onChange: () => {}, allowMultipleMetrics: true, - metricsFilter: () => {}, }; -test('should render metrics editor', async () => { +test('should render metrics editor', () => { const component = shallow(); expect(component).toMatchSnapshot(); }); -test('should add default count metric when metrics is empty array', async () => { +test('should add default count metric when metrics is empty array', () => { const component = shallow(); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/plugins/maps/public/components/metrics_editor.js b/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.tsx similarity index 54% rename from x-pack/plugins/maps/public/components/metrics_editor.js rename to x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.tsx index 7d4d7bf3ec7ab..17cfc5f62fee5 100644 --- a/x-pack/plugins/maps/public/components/metrics_editor.js +++ b/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.tsx @@ -5,48 +5,43 @@ */ import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButtonEmpty, EuiSpacer, EuiTextAlign } from '@elastic/eui'; +import { EuiButtonEmpty, EuiComboBoxOptionOption, EuiSpacer, EuiTextAlign } from '@elastic/eui'; import { MetricEditor } from './metric_editor'; -import { DEFAULT_METRIC } from '../classes/sources/es_agg_source'; +// @ts-expect-error +import { DEFAULT_METRIC } from '../../classes/sources/es_agg_source'; +import { IFieldType } from '../../../../../../src/plugins/data/public'; +import { AggDescriptor } from '../../../common/descriptor_types'; +import { AGG_TYPE } from '../../../common/constants'; -export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics, metricsFilter }) { +interface Props { + allowMultipleMetrics: boolean; + metrics: AggDescriptor[]; + fields: IFieldType[]; + onChange: (metrics: AggDescriptor[]) => void; + metricsFilter?: (metricOption: EuiComboBoxOptionOption) => boolean; +} + +export function MetricsEditor({ + fields, + metrics = [DEFAULT_METRIC], + onChange, + allowMultipleMetrics = true, + metricsFilter, +}: Props) { function renderMetrics() { // There was a bug in 7.8 that initialized metrics to []. // This check is needed to handle any saved objects created before the bug was patched. const nonEmptyMetrics = metrics.length === 0 ? [DEFAULT_METRIC] : metrics; return nonEmptyMetrics.map((metric, index) => { - const onMetricChange = (metric) => { - onChange([...metrics.slice(0, index), metric, ...metrics.slice(index + 1)]); + const onMetricChange = (updatedMetric: AggDescriptor) => { + onChange([...metrics.slice(0, index), updatedMetric, ...metrics.slice(index + 1)]); }; const onRemove = () => { onChange([...metrics.slice(0, index), ...metrics.slice(index + 1)]); }; - let removeButton; - if (index > 0) { - removeButton = ( -
- - - -
- ); - } return (
0} + onRemove={onRemove} />
); @@ -62,7 +58,7 @@ export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics, } function addMetric() { - onChange([...metrics, {}]); + onChange([...metrics, { type: AGG_TYPE.AVG }]); } function renderAddMetricButton() { @@ -71,7 +67,7 @@ export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics, } return ( - <> + @@ -81,7 +77,7 @@ export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics, /> - + ); } @@ -93,16 +89,3 @@ export function MetricsEditor({ fields, metrics, onChange, allowMultipleMetrics,
); } - -MetricsEditor.propTypes = { - metrics: PropTypes.array, - fields: PropTypes.array, - onChange: PropTypes.func.isRequired, - allowMultipleMetrics: PropTypes.bool, - metricsFilter: PropTypes.func, -}; - -MetricsEditor.defaultProps = { - metrics: [DEFAULT_METRIC], - allowMultipleMetrics: true, -}; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js index 3cd8a3c42879a..e0e1556ecde06 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/metrics_expression.test.js @@ -4,12 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -jest.mock('../../../../components/metric_editor', () => ({ - MetricsEditor: () => { - return
mockMetricsEditor
; - }, -})); - import React from 'react'; import { shallow } from 'enzyme'; import { MetricsExpression } from './metrics_expression'; From 42a693475dbec77cba624a6b58f492d456aefddf Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Wed, 9 Sep 2020 14:05:01 -0400 Subject: [PATCH 12/25] [Security_solution][Detections] Refactor signal ancestry to allow multiple parents (#76531) * Refactors signal ancestry to allow multiple parents * Fix depth calculation for 7.10+ signals on pre-7.10 signals * Comment build_signal functions * Rename buildAncestorsSignal to buildAncestors * Update detection engine depth test scripts and docs * Update halting test readme * Match up rule ids in readme * Continue populating signal.parent along with signal.parents * pr comments Co-authored-by: Elastic Machine --- .../routes/index/signals_mapping.json | 25 ++ .../signals_on_signals/depth_test/README.md | 244 +++++++++-------- .../depth_test/query_single_id.json | 2 +- .../depth_test/signal_on_signal_depth_1.json | 4 +- .../depth_test/signal_on_signal_depth_2.json | 4 +- .../signals_on_signals/halting_test/README.md | 182 +++++++------ .../signals/__mocks__/es_results.ts | 10 +- .../signals/build_bulk_body.test.ts | 60 +++-- .../signals/build_bulk_body.ts | 9 +- .../signals/build_rule.test.ts | 102 ++++++- .../detection_engine/signals/build_rule.ts | 16 +- .../signals/build_signal.test.ts | 250 ++++-------------- .../detection_engine/signals/build_signal.ts | 82 +++--- .../signals/signal_rule_alert_type.ts | 3 +- .../signals/single_bulk_create.test.ts | 32 +-- .../signals/single_bulk_create.ts | 5 +- .../lib/detection_engine/signals/types.ts | 19 +- 17 files changed, 568 insertions(+), 481 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json index 7d80a319e9e52..cfce019910071 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json @@ -22,11 +22,33 @@ } } }, + "parents": { + "properties": { + "rule": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "depth": { + "type": "long" + } + } + }, "ancestors": { "properties": { "rule": { "type": "keyword" }, + "index": { + "type": "keyword" + }, "id": { "type": "keyword" }, @@ -299,6 +321,9 @@ }, "threshold_count": { "type": "float" + }, + "depth": { + "type": "integer" } } } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/README.md index 2310ba979da20..7cf7d11e4c1f8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/README.md +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/README.md @@ -22,7 +22,7 @@ which will write a single signal document into the signals index by searching fo signal_on_signal_depth_1.json ``` -which has this key part of its query: `"query": "signal.parent.depth: 1 and _id: *"` which will only create signals +which has this key part of its query: `"query": "signal.depth: 1 and _id: *"` which will only create signals from all signals that point directly to an event (signal -> event). Then a second rule called @@ -34,7 +34,7 @@ signal_on_signal_depth_2.json which will only create signals from all signals that point directly to another signal (signal -> signal) with this query ```json -"query": "signal.parent.depth: 2 and _id: *" +"query": "signal.depth: 2 and _id: *" ``` ## Setup @@ -90,38 +90,43 @@ And then you can query against that: GET .siem-signals-default/_search ``` -Check your parent section of the signal and you will see something like this: +Check your `signal` section of the signal and you will see something like this: ```json -"parent" : { - "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", - "id" : "o8G7vm8BvLT8jmu5B1-M", - "type" : "event", - "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 -}, -"ancestors" : [ +"parents" : [ { - "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 } -] +], +"ancestors" : [ + { + "id" : "o8G7vm8BvLT8jmu5B1-M", + "type" : "event", + "index" : "filebeat-8.0.0-2019.12.18-000001", + "depth" : 0 + }, +], +"depth": 1, +"rule": { + "id": "74e0dd0c-4609-416f-b65e-90f8b2564612" +} ``` -The parent and ancestors structure is defined as: +The parents structure is defined as: ``` -rule -> The id of the rule. You can view the rule by ./get_rule_by_rule_id.sh ded57b36-9c4e-4ee4-805d-be4e92033e41 +rule -> The id of the rule, if the parent was generated by a rule. You can view the rule by ./get_rule_by_rule_id.sh ded57b36-9c4e-4ee4-805d-be4e92033e41 id -> The original _id of the document type -> The type of the document, it will be either event or signal index -> The original location of the index -depth -> The depth of this signal. It will be at least 1 to indicate it is a signal generated from a event. Otherwise 2 or more to indicate a signal on signal and what depth we are at -ancestors -> An array tracking all of the parents of this particular signal. As depth increases this will too. +depth -> The depth of the parent event/signal. It will be 0 if the parent is an event, or 1+ if the parent is another signal. ``` +The ancestors structure has the same fields as parents, but is an array of all ancestors (parents, grandparents, etc) of the signal. + This is indicating that you have a single parent of an event from the signal (signal -> event) and this document has a single ancestor of that event. Each 30 seconds that goes it will use de-duplication technique to ensure that this signal is not re-inserted. If after each 30 seconds you DO SEE multiple signals then the bug is a de-duplication bug and a critical bug. If you ever see a duplicate rule in the @@ -138,55 +143,64 @@ running in the system which are generating signals on top of signals. After 30 s documents in the signals index. The first signal is our original (signal -> event) document with a rule id: ```json -"parent" : { - "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", - "id" : "o8G7vm8BvLT8jmu5B1-M", - "type" : "event", - "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 -}, +"parents" : [ + { + "id" : "o8G7vm8BvLT8jmu5B1-M", + "type" : "event", + "index" : "filebeat-8.0.0-2019.12.18-000001", + "depth" : 0 + } +], "ancestors" : [ { - "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 } -] +], +"depth": 1, +"rule": { + "id": "74e0dd0c-4609-416f-b65e-90f8b2564612" +} ``` and the second document is a signal on top of a signal like so: ```json -"parent" : { - "rule" : "1d3b3735-66ef-4e53-b7f5-4340026cc40c", - "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", - "type" : "signal", - "index" : ".siem-signals-default-000001", - "depth" : 2 -}, -"ancestors" : [ +"parents" : [ { "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", + "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", + "type" : "signal", + "index" : ".siem-signals-default-000001", + "depth" : 1 + } +] +"ancestors" : [ + { "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 }, { - "rule" : "1d3b3735-66ef-4e53-b7f5-4340026cc40c", + "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 } -] +], +"depth": 2, +"rule": { + "id": "1d3b3735-66ef-4e53-b7f5-4340026cc40c" +} ``` Notice that the depth indicates it is at level 2 and its parent is that of a signal. Also notice that the ancestors is an array of size 2 indicating that this signal terminates at an event. Each and every signal ancestors array should terminate at an event and should ONLY contain 1 -event and NEVER 2 or more events. After 30+ seconds you should NOT see any new documents being created and you should be stable +event and NEVER 2 or more events for KQL query based rules. EQL query based rules that use sequences may have multiple parents at the same level. After 30+ seconds you should NOT see any new documents being created and you should be stable at 2. Otherwise we have AND/OR a de-duplication issue, signal on signal issue. Now, post this same rule a second time as a second instance which is going to run against these two documents. @@ -212,79 +226,93 @@ The expected behavior is that eventually you will get 3 total documents but not The original event rule 74e0dd0c-4609-416f-b65e-90f8b2564612 (event -> signal) ```json -"parent" : { - "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", - "id" : "o8G7vm8BvLT8jmu5B1-M", - "type" : "event", - "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 -}, +"parents" : [ + { + "id" : "o8G7vm8BvLT8jmu5B1-M", + "type" : "event", + "index" : "filebeat-8.0.0-2019.12.18-000001", + "depth" : 0 + } +], "ancestors" : [ { - "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 } -] +], +"depth": 1, +"rule": { + "id": "74e0dd0c-4609-416f-b65e-90f8b2564612" +} ``` The first signal to signal rule 1d3b3735-66ef-4e53-b7f5-4340026cc40c (signal -> event) ```json -"parent" : { - "rule" : "1d3b3735-66ef-4e53-b7f5-4340026cc40c", - "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", - "type" : "signal", - "index" : ".siem-signals-default-000001", - "depth" : 2 -}, -"ancestors" : [ +"parents" : [ { "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", + "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", + "type" : "signal", + "index" : ".siem-signals-default-000001", + "depth" : 1 + } +] +"ancestors" : [ + { "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 }, { - "rule" : "1d3b3735-66ef-4e53-b7f5-4340026cc40c", + "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 } -] +], +"depth": 2, +"rule": { + "id": "1d3b3735-66ef-4e53-b7f5-4340026cc40c" +} ``` Then our second signal to signal rule c93ddb57-e7e9-4973-9886-72ddefb4d22e (signal -> event) which finds the same thing as the first signal to signal ```json -"parent" : { - "rule" : "c93ddb57-e7e9-4973-9886-72ddefb4d22e", - "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", - "type" : "signal", - "index" : ".siem-signals-default-000001", - "depth" : 2 -}, -"ancestors" : [ +"parents" : [ { "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", + "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", + "type" : "signal", + "index" : ".siem-signals-default-000001", + "depth" : 1 + } +], +"ancestors" : [ + { "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 }, { - "rule" : "c93ddb57-e7e9-4973-9886-72ddefb4d22e", + "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 } -] +], +"depth": 2, +"rule": { + "id": "c93ddb57-e7e9-4973-9886-72ddefb4d22e" +} ``` We should be able to post this depth level as many times as we want and get only 1 new document each time. If we decide though to @@ -298,69 +326,79 @@ The expectation is that a document for each of the previous depth 1 documents wo depth 1 rules running then the signals at depth 2 will produce two new ones and those two will look like so: ```json -"parent" : { - "rule" : "a1f7b520-5bfd-451d-af59-428f60753fee", - "id" : "365236ce5e77770508152403b4e16613f407ae4b1a135a450dcfec427f2a3231", - "type" : "signal", - "index" : ".siem-signals-default-000001", - "depth" : 3 -}, +"parents" : [ + { + "rule" : "1d3b3735-66ef-4e53-b7f5-4340026cc40c", + "id" : "365236ce5e77770508152403b4e16613f407ae4b1a135a450dcfec427f2a3231", + "type" : "signal", + "index" : ".siem-signals-default-000001", + "depth" : 2 + } +], "ancestors" : [ { - "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 }, { - "rule" : "1d3b3735-66ef-4e53-b7f5-4340026cc40c", + "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 }, { - "rule" : "a1f7b520-5bfd-451d-af59-428f60753fee", + "rule" : "1d3b3735-66ef-4e53-b7f5-4340026cc40c", "id" : "365236ce5e77770508152403b4e16613f407ae4b1a135a450dcfec427f2a3231", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 3 + "depth" : 2 } -] +], +"depth": 3, +"rule": { + "id": "a1f7b520-5bfd-451d-af59-428f60753fee" +} ``` ```json -"parent" : { - "rule" : "a1f7b520-5bfd-451d-af59-428f60753fee", - "id" : "e8b1f1adb40fd642fa524dea89ef94232e67b05e99fb0b2683f1e47e90b759fb", - "type" : "signal", - "index" : ".siem-signals-default-000001", - "depth" : 3 -}, +"parents" : [ + { + "rule" : "c93ddb57-e7e9-4973-9886-72ddefb4d22e", + "id" : "e8b1f1adb40fd642fa524dea89ef94232e67b05e99fb0b2683f1e47e90b759fb", + "type" : "signal", + "index" : ".siem-signals-default-000001", + "depth" : 2 + } +], "ancestors" : [ { - "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 }, { - "rule" : "c93ddb57-e7e9-4973-9886-72ddefb4d22e", + "rule" : "74e0dd0c-4609-416f-b65e-90f8b2564612", "id" : "4cc69c1cbecdd2ace4075fd1d8a5c28e7d46e4bf31aecc8d2da39252c50c96b4", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 }, { - "rule" : "a1f7b520-5bfd-451d-af59-428f60753fee", + "rule" : "c93ddb57-e7e9-4973-9886-72ddefb4d22e", "id" : "e8b1f1adb40fd642fa524dea89ef94232e67b05e99fb0b2683f1e47e90b759fb", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 3 + "depth" : 2 } -] +], +"depth": 3, +"rule": { + "id": "a1f7b520-5bfd-451d-af59-428f60753fee" +} ``` The total number of documents should be 5 at this point. If you were to post this same rule a second time to get a second instance diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/query_single_id.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/query_single_id.json index dc05c656d7cf1..305aa34992623 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/query_single_id.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/query_single_id.json @@ -7,6 +7,6 @@ "from": "now-1d", "interval": "30s", "to": "now", - "query": "_id: o8G7vm8BvLT8jmu5B1-M", + "query": "event.id: 08cde4aa-d249-4e6b-8300-06f3d56c7fe7", "enabled": true } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/signal_on_signal_depth_1.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/signal_on_signal_depth_1.json index fb13413a02791..c9132ddb0a590 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/signal_on_signal_depth_1.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/signal_on_signal_depth_1.json @@ -7,7 +7,7 @@ "from": "now-1d", "interval": "30s", "to": "now", - "query": "signal.parent.depth: 1 and _id: *", + "query": "signal.depth: 1 and _id: *", "enabled": true, - "index": ".siem-signals-default" + "index": [".siem-signals-default"] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/signal_on_signal_depth_2.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/signal_on_signal_depth_2.json index c1b7594653ec7..d1a2749792686 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/signal_on_signal_depth_2.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/depth_test/signal_on_signal_depth_2.json @@ -7,7 +7,7 @@ "from": "now-1d", "interval": "30s", "to": "now", - "query": "signal.parent.depth: 2 and _id: *", + "query": "signal.depth: 2 and _id: *", "enabled": true, - "index": ".siem-signals-default" + "index": [".siem-signals-default"] } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/halting_test/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/halting_test/README.md index b1a83f5317776..01b21bf762e44 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/halting_test/README.md +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/rules/test_cases/signals_on_signals/halting_test/README.md @@ -69,38 +69,43 @@ And then you can query against that: GET .siem-signals-default/_search ``` -Check your parent section of the signal and you will see something like this: +Check your `signal` section of the signal and you will see something like this: ```json -"parent" : { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", - "id" : "o8G7vm8BvLT8jmu5B1-M", - "type" : "event", - "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 -}, -"ancestors" : [ +"parents" : [ { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 } -] +], +"ancestors" : [ + { + "id" : "o8G7vm8BvLT8jmu5B1-M", + "type" : "event", + "index" : "filebeat-8.0.0-2019.12.18-000001", + "depth" : 0 + }, +], +"depth": 1, +"rule": { + "id": "ded57b36-9c4e-4ee4-805d-be4e92033e41" +} ``` -The parent and ancestors structure is defined as: +The parents structure is defined as: ``` -rule -> The id of the rule. You can view the rule by ./get_rule_by_rule_id.sh ded57b36-9c4e-4ee4-805d-be4e92033e41 +rule -> The id of the rule, if the parent was generated by a rule. You can view the rule by ./get_rule_by_rule_id.sh ded57b36-9c4e-4ee4-805d-be4e92033e41 id -> The original _id of the document type -> The type of the document, it will be either event or signal index -> The original location of the index -depth -> The depth of this signal. It will be at least 1 to indicate it is a signal generated from a event. Otherwise 2 or more to indicate a signal on signal and what depth we are at -ancestors -> An array tracking all of the parents of this particular signal. As depth increases this will too. +depth -> The depth of the parent event/signal. It will be 0 if the parent is an event, or 1+ if the parent is another signal. ``` +The ancestors structure has the same fields as parents, but is an array of all ancestors (parents, grandparents, etc) of the signal. + This is indicating that you have a single parent of an event from the signal (signal -> event) and this document has a single ancestor of that event. Each 30 seconds that goes it will use de-duplication technique to ensure that this signal is not re-inserted. If after each 30 seconds you DO SEE multiple signals then the bug is a de-duplication bug and a critical bug. If you ever see a duplicate rule in the @@ -119,22 +124,26 @@ documents in the signals index. The first signal is our original (signal -> even (signal -> event) ```json -"parent" : { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", - "id" : "o8G7vm8BvLT8jmu5B1-M", - "type" : "event", - "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 -}, -"ancestors" : [ +"parents" : [ { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 } -] +], +"ancestors" : [ + { + "id" : "o8G7vm8BvLT8jmu5B1-M", + "type" : "event", + "index" : "filebeat-8.0.0-2019.12.18-000001", + "depth" : 0 + }, +], +"depth": 1, +"rule": { + "id": "ded57b36-9c4e-4ee4-805d-be4e92033e41" +} ``` and the second document is a signal on top of a signal like so: @@ -143,28 +152,31 @@ and the second document is a signal on top of a signal like so: ```json "parent" : { - "rule" : "161fa5b8-0b96-4985-b066-0d99b2bcb904", + "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "9d8710925adbf1a9c469621805407e74334dd08ca2c2ea414840fe971a571938", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 }, "ancestors" : [ { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 }, { - "rule" : "161fa5b8-0b96-4985-b066-0d99b2bcb904", + "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "9d8710925adbf1a9c469621805407e74334dd08ca2c2ea414840fe971a571938", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 } -] +], +"depth": 2, +"rule": { + "id": "161fa5b8-0b96-4985-b066-0d99b2bcb904" +} ``` Notice that the depth indicates it is at level 2 and its parent is that of a signal. Also notice that the ancestors is an array of size 2 @@ -195,50 +207,57 @@ The expected behavior is that eventually you will get 5 total documents but not The original event rule ded57b36-9c4e-4ee4-805d-be4e92033e41 (event -> signal) ```json -"parent" : { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", - "id" : "o8G7vm8BvLT8jmu5B1-M", - "type" : "event", - "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 -}, -"ancestors" : [ +"parents" : [ { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 } -] +], +"ancestors" : [ + { + "id" : "o8G7vm8BvLT8jmu5B1-M", + "type" : "event", + "index" : "filebeat-8.0.0-2019.12.18-000001", + "depth" : 0 + }, +], +"depth": 1, +"rule": { + "id": "ded57b36-9c4e-4ee4-805d-be4e92033e41" +} ``` The first signal to signal rule 161fa5b8-0b96-4985-b066-0d99b2bcb904 (signal -> event) ```json "parent" : { - "rule" : "161fa5b8-0b96-4985-b066-0d99b2bcb904", + "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "9d8710925adbf1a9c469621805407e74334dd08ca2c2ea414840fe971a571938", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 }, "ancestors" : [ { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 }, { - "rule" : "161fa5b8-0b96-4985-b066-0d99b2bcb904", + "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "9d8710925adbf1a9c469621805407e74334dd08ca2c2ea414840fe971a571938", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 } -] +], +"depth": 2, +"rule": { + "id": "161fa5b8-0b96-4985-b066-0d99b2bcb904" +} ``` Then our second signal to signal rule f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406 (signal -> event) which finds the same thing as the first @@ -246,28 +265,31 @@ signal to signal ```json "parent" : { - "rule" : "f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406", + "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "9d8710925adbf1a9c469621805407e74334dd08ca2c2ea414840fe971a571938", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 }, "ancestors" : [ { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 }, { - "rule" : "f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406", + "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "9d8710925adbf1a9c469621805407e74334dd08ca2c2ea414840fe971a571938", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 } -] +], +"depth": 2, +"rule": { + "id": "f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406" +} ``` But then f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406 also finds the first signal to signal rule from 161fa5b8-0b96-4985-b066-0d99b2bcb904 @@ -275,35 +297,38 @@ and writes that document out with a depth of 3. (signal -> signal -> event) ```json "parent" : { - "rule" : "f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406", + "rule" : "161fa5b8-0b96-4985-b066-0d99b2bcb904", "id" : "c627e5e2576f1b10952c6c57249947e89b6153b763a59fb9e391d0b56be8e7fe", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 3 + "depth" : 2 }, "ancestors" : [ { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 }, { - "rule" : "161fa5b8-0b96-4985-b066-0d99b2bcb904", + "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "9d8710925adbf1a9c469621805407e74334dd08ca2c2ea414840fe971a571938", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 }, { - "rule" : "f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406", + "rule" : "161fa5b8-0b96-4985-b066-0d99b2bcb904", "id" : "c627e5e2576f1b10952c6c57249947e89b6153b763a59fb9e391d0b56be8e7fe", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 3 + "depth" : 2 } -] +], +"depth": 3, +"rule": { + "id": "f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406" +} ``` Since it wrote that document, the first signal to signal 161fa5b8-0b96-4985-b066-0d99b2bcb904 writes out it found this newly created signal @@ -311,35 +336,38 @@ Since it wrote that document, the first signal to signal 161fa5b8-0b96-4985-b066 ```json "parent" : { - "rule" : "161fa5b8-0b96-4985-b066-0d99b2bcb904", + "rule" : "f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406", "id" : "efbe514e8d806a5ef3da7658cfa73961e25befefc84f622e963b45dcac798868", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 3 + "depth" : 2 }, "ancestors" : [ { - "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "o8G7vm8BvLT8jmu5B1-M", "type" : "event", "index" : "filebeat-8.0.0-2019.12.18-000001", - "depth" : 1 + "depth" : 0 }, { - "rule" : "f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406", + "rule" : "ded57b36-9c4e-4ee4-805d-be4e92033e41", "id" : "9d8710925adbf1a9c469621805407e74334dd08ca2c2ea414840fe971a571938", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 2 + "depth" : 1 }, { - "rule" : "161fa5b8-0b96-4985-b066-0d99b2bcb904", + "rule" : "f2b70c4a-4d8f-4db5-9ed7-d3ab0630e406", "id" : "efbe514e8d806a5ef3da7658cfa73961e25befefc84f622e963b45dcac798868", "type" : "signal", "index" : ".siem-signals-default-000001", - "depth" : 3 + "depth" : 2 } -] +], +"depth": 3, +"rule": { + "id": "161fa5b8-0b96-4985-b066-0d99b2bcb904" +} ``` You will be "halted" at this point as the signal ancestry and de-duplication ensures that we do not report twice on signals and that we do not diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 95ec753c21fd8..9d3eb29be08dd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -149,21 +149,23 @@ export const sampleDocWithAncestors = (): SignalSearchResponse => { delete sampleDoc._source.source; sampleDoc._source.signal = { parent: { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ancestors: [ { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], + rule: { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }, + depth: 1, }; return { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index ee83c826371bc..967dc5331e46b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -48,19 +48,25 @@ describe('buildBulkBody', () => { }, signal: { parent: { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: sampleIdGuid, type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, + parents: [ + { + id: sampleIdGuid, + type: 'event', + index: 'myFakeSignalIndex', + depth: 0, + }, + ], ancestors: [ { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: sampleIdGuid, type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], original_time: '2020-04-20T21:27:45+0000', @@ -102,6 +108,7 @@ describe('buildBulkBody', () => { updated_at: fakeSignalSourceHit.signal.rule?.updated_at, exceptions_list: getListArrayMock(), }, + depth: 1, }, }; expect(fakeSignalSourceHit).toEqual(expected); @@ -151,19 +158,25 @@ describe('buildBulkBody', () => { module: 'system', }, parent: { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: sampleIdGuid, type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, + parents: [ + { + id: sampleIdGuid, + type: 'event', + index: 'myFakeSignalIndex', + depth: 0, + }, + ], ancestors: [ { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: sampleIdGuid, type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], original_time: '2020-04-20T21:27:45+0000', @@ -205,6 +218,7 @@ describe('buildBulkBody', () => { threat: [], exceptions_list: getListArrayMock(), }, + depth: 1, }, }; expect(fakeSignalSourceHit).toEqual(expected); @@ -252,19 +266,25 @@ describe('buildBulkBody', () => { module: 'system', }, parent: { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: sampleIdGuid, type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, + parents: [ + { + id: sampleIdGuid, + type: 'event', + index: 'myFakeSignalIndex', + depth: 0, + }, + ], ancestors: [ { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: sampleIdGuid, type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], original_time: '2020-04-20T21:27:45+0000', @@ -306,6 +326,7 @@ describe('buildBulkBody', () => { throttle: 'no_actions', exceptions_list: getListArrayMock(), }, + depth: 1, }, }; expect(fakeSignalSourceHit).toEqual(expected); @@ -346,19 +367,25 @@ describe('buildBulkBody', () => { kind: 'event', }, parent: { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: sampleIdGuid, type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, + parents: [ + { + id: sampleIdGuid, + type: 'event', + index: 'myFakeSignalIndex', + depth: 0, + }, + ], ancestors: [ { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: sampleIdGuid, type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], original_time: '2020-04-20T21:27:45+0000', @@ -400,6 +427,7 @@ describe('buildBulkBody', () => { throttle: 'no_actions', exceptions_list: getListArrayMock(), }, + depth: 1, }, }; expect(fakeSignalSourceHit).toEqual(expected); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index 218750ac30a2a..7be97e46f91f2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SignalSourceHit, SignalHit } from './types'; +import { SignalSourceHit, SignalHit, Signal } from './types'; import { buildRule } from './build_rule'; -import { buildSignal } from './build_signal'; +import { additionalSignalFields, buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; import { RuleAlertAction } from '../../../../common/detection_engine/types'; import { RuleTypeParams } from '../types'; @@ -58,7 +58,10 @@ export const buildBulkBody = ({ tags, throttle, }); - const signal = buildSignal(doc, rule); + const signal: Signal = { + ...buildSignal([doc], rule), + ...additionalSignalFields(doc), + }; const event = buildEventTypeSignal(doc); const signalHit: SignalHit = { ...doc._source, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts index 7257e5952ff05..ba815a0b62f0d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.test.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { buildRule } from './build_rule'; +import { buildRule, removeInternalTagsFromRule } from './build_rule'; import { sampleDocNoSortId, sampleRuleAlertParams, sampleRuleGuid } from './__mocks__/es_results'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; +import { INTERNAL_RULE_ID_KEY, INTERNAL_IMMUTABLE_KEY } from '../../../../common/constants'; +import { getPartialRulesSchemaMock } from '../../../../common/detection_engine/schemas/response/rules_schema.mocks'; describe('buildRule', () => { beforeEach(() => { @@ -208,4 +210,102 @@ describe('buildRule', () => { }; expect(rule).toEqual(expected); }); + + test('it builds a rule and removes internal tags', () => { + const ruleParams = sampleRuleAlertParams(); + const rule = buildRule({ + actions: [], + doc: sampleDocNoSortId(), + ruleParams, + name: 'some-name', + id: sampleRuleGuid, + enabled: false, + createdAt: '2020-01-28T15:58:34.810Z', + updatedAt: '2020-01-28T15:59:14.004Z', + createdBy: 'elastic', + updatedBy: 'elastic', + interval: 'some interval', + tags: [ + 'some fake tag 1', + 'some fake tag 2', + `${INTERNAL_RULE_ID_KEY}:rule-1`, + `${INTERNAL_IMMUTABLE_KEY}:true`, + ], + throttle: 'no_actions', + }); + const expected: Partial = { + actions: [], + author: ['Elastic'], + building_block_type: 'default', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: false, + false_positives: [], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: 'some interval', + language: 'kuery', + license: 'Elastic License', + max_signals: 10000, + name: 'some-name', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://google.com'], + risk_score: 50, + risk_score_mapping: [], + rule_id: 'rule-1', + severity: 'high', + severity_mapping: [], + tags: ['some fake tag 1', 'some fake tag 2'], + threat: [], + to: 'now', + type: 'query', + note: '', + updated_by: 'elastic', + updated_at: rule.updated_at, + created_at: rule.created_at, + throttle: 'no_actions', + exceptions_list: getListArrayMock(), + version: 1, + }; + expect(rule).toEqual(expected); + }); + + test('it removes internal tags from a typical rule', () => { + const rule = getPartialRulesSchemaMock(); + rule.tags = [ + 'some fake tag 1', + 'some fake tag 2', + `${INTERNAL_RULE_ID_KEY}:rule-1`, + `${INTERNAL_IMMUTABLE_KEY}:true`, + ]; + const noInternals = removeInternalTagsFromRule(rule); + expect(noInternals).toEqual(getPartialRulesSchemaMock()); + }); + + test('it works with an empty array', () => { + const rule = getPartialRulesSchemaMock(); + rule.tags = []; + const noInternals = removeInternalTagsFromRule(rule); + const expected = getPartialRulesSchemaMock(); + expected.tags = []; + expect(noInternals).toEqual(expected); + }); + + test('it works if tags does not exist', () => { + const rule = getPartialRulesSchemaMock(); + delete rule.tags; + const noInternals = removeInternalTagsFromRule(rule); + const expected = getPartialRulesSchemaMock(); + delete expected.tags; + expect(noInternals).toEqual(expected); + }); + + test('it works if tags contains normal values and no internal values', () => { + const rule = getPartialRulesSchemaMock(); + const noInternals = removeInternalTagsFromRule(rule); + expect(noInternals).toEqual(rule); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts index e02a0154d63c9..aacf9b8be31b4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts @@ -12,6 +12,7 @@ import { buildRiskScoreFromMapping } from './mappings/build_risk_score_from_mapp import { SignalSourceHit } from './types'; import { buildSeverityFromMapping } from './mappings/build_severity_from_mapping'; import { buildRuleNameFromMapping } from './mappings/build_rule_name_from_mapping'; +import { INTERNAL_IDENTIFIER } from '../../../../common/constants'; interface BuildRuleParams { ruleParams: RuleTypeParams; @@ -64,7 +65,7 @@ export const buildRule = ({ const meta = { ...ruleParams.meta, ...riskScoreMeta, ...severityMeta, ...ruleNameMeta }; - return pickBy((value: unknown) => value != null, { + const rule = pickBy((value: unknown) => value != null, { id, rule_id: ruleParams.ruleId ?? '(unknown rule_id)', actions, @@ -111,4 +112,17 @@ export const buildRule = ({ anomaly_threshold: ruleParams.anomalyThreshold, threshold: ruleParams.threshold, }); + return removeInternalTagsFromRule(rule); +}; + +export const removeInternalTagsFromRule = (rule: Partial): Partial => { + if (rule.tags == null) { + return rule; + } else { + const ruleWithoutInternalTags: Partial = { + ...rule, + tags: rule.tags.filter((tag) => !tag.startsWith(INTERNAL_IDENTIFIER)), + }; + return ruleWithoutInternalTags; + } }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts index 6aebf8815659a..d684807a09126 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts @@ -5,14 +5,8 @@ */ import { sampleDocNoSortId } from './__mocks__/es_results'; -import { - buildSignal, - buildAncestor, - buildAncestorsSignal, - removeInternalTagsFromRule, -} from './build_signal'; +import { buildSignal, buildParent, buildAncestors, additionalSignalFields } from './build_signal'; import { Signal, Ancestor } from './types'; -import { INTERNAL_RULE_ID_KEY, INTERNAL_IMMUTABLE_KEY } from '../../../../common/constants'; import { getPartialRulesSchemaMock } from '../../../../common/detection_engine/schemas/response/rules_schema.mocks'; describe('buildSignal', () => { @@ -24,22 +18,31 @@ describe('buildSignal', () => { const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); delete doc._source.event; const rule = getPartialRulesSchemaMock(); - const signal = buildSignal(doc, rule); + const signal = { + ...buildSignal([doc], rule), + ...additionalSignalFields(doc), + }; const expected: Signal = { parent: { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, + parents: [ + { + id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', + type: 'event', + index: 'myFakeSignalIndex', + depth: 0, + }, + ], ancestors: [ { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], original_time: '2020-04-20T21:27:45+0000', @@ -71,6 +74,7 @@ describe('buildSignal', () => { updated_at: signal.rule.updated_at, created_at: signal.rule.created_at, }, + depth: 1, }; expect(signal).toEqual(expected); }); @@ -84,94 +88,31 @@ describe('buildSignal', () => { module: 'system', }; const rule = getPartialRulesSchemaMock(); - const signal = buildSignal(doc, rule); + const signal = { + ...buildSignal([doc], rule), + ...additionalSignalFields(doc), + }; const expected: Signal = { parent: { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, - ancestors: [ + parents: [ { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], - original_time: '2020-04-20T21:27:45+0000', - original_event: { - action: 'socket_opened', - dataset: 'socket', - kind: 'event', - module: 'system', - }, - status: 'open', - rule: { - created_by: 'elastic', - description: 'Detecting root and admin users', - enabled: true, - false_positives: [], - from: 'now-6m', - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - immutable: false, - index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], - interval: '5m', - risk_score: 50, - rule_id: 'rule-1', - language: 'kuery', - max_signals: 100, - name: 'Detect Root/Admin Users', - output_index: '.siem-signals', - query: 'user.name: root or user.name: admin', - references: ['http://www.example.com', 'https://ww.example.com'], - severity: 'high', - updated_by: 'elastic', - tags: ['some fake tag 1', 'some fake tag 2'], - to: 'now', - type: 'query', - note: '', - updated_at: signal.rule.updated_at, - created_at: signal.rule.created_at, - }, - }; - expect(signal).toEqual(expected); - }); - - test('it builds a signal as expected with original_event if is present and without internal tags in them', () => { - const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); - doc._source.event = { - action: 'socket_opened', - dataset: 'socket', - kind: 'event', - module: 'system', - }; - const rule = getPartialRulesSchemaMock(); - rule.tags = [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ]; - const signal = buildSignal(doc, rule); - const expected: Signal = { - parent: { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', - type: 'event', - index: 'myFakeSignalIndex', - depth: 1, - }, ancestors: [ { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], original_time: '2020-04-20T21:27:45+0000', @@ -209,6 +150,7 @@ describe('buildSignal', () => { updated_at: signal.rule.updated_at, created_at: signal.rule.created_at, }, + depth: 1, }; expect(signal).toEqual(expected); }); @@ -221,14 +163,12 @@ describe('buildSignal', () => { kind: 'event', module: 'system', }; - const rule = getPartialRulesSchemaMock(); - const signal = buildAncestor(doc, rule); + const signal = buildParent(doc); const expected: Ancestor = { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }; expect(signal).toEqual(expected); }); @@ -242,76 +182,34 @@ describe('buildSignal', () => { module: 'system', }; doc._source.signal = { - parent: { - rule: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', - id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', - type: 'event', - index: 'myFakeSignalIndex', - depth: 1, - }, - ancestors: [ + parents: [ { - rule: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], - }; - const rule = getPartialRulesSchemaMock(); - const signal = buildAncestor(doc, rule); - const expected: Ancestor = { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', - type: 'signal', - index: 'myFakeSignalIndex', - depth: 2, - }; - expect(signal).toEqual(expected); - }); - - test('it builds a ancestor correctly if the parent does exist without internal tags in them', () => { - const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); - doc._source.event = { - action: 'socket_opened', - dataset: 'socket', - kind: 'event', - module: 'system', - }; - doc._source.signal = { - parent: { - rule: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', - id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', - type: 'event', - index: 'myFakeSignalIndex', - depth: 1, - }, ancestors: [ { - rule: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], + depth: 1, + rule: { + id: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', + }, }; - const rule = getPartialRulesSchemaMock(); - rule.tags = [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ]; - - const signal = buildAncestor(doc, rule); + const signal = buildParent(doc); const expected: Ancestor = { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + rule: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'signal', index: 'myFakeSignalIndex', - depth: 2, + depth: 1, }; expect(signal).toEqual(expected); }); @@ -324,15 +222,13 @@ describe('buildSignal', () => { kind: 'event', module: 'system', }; - const rule = getPartialRulesSchemaMock(); - const signal = buildAncestorsSignal(doc, rule); + const signal = buildAncestors(doc); const expected: Ancestor[] = [ { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ]; expect(signal).toEqual(expected); @@ -347,77 +243,43 @@ describe('buildSignal', () => { module: 'system', }; doc._source.signal = { - parent: { - rule: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', - id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', - type: 'event', - index: 'myFakeSignalIndex', - depth: 1, - }, + parents: [ + { + id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', + type: 'event', + index: 'myFakeSignalIndex', + depth: 0, + }, + ], ancestors: [ { - rule: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, ], + rule: { + id: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', + }, + depth: 1, }; - const rule = getPartialRulesSchemaMock(); - const signal = buildAncestorsSignal(doc, rule); + const signal = buildAncestors(doc); const expected: Ancestor[] = [ { - rule: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', id: '730ddf9e-5a00-4f85-9ddf-5878ca511a87', type: 'event', index: 'myFakeSignalIndex', - depth: 1, + depth: 0, }, { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + rule: '98c0bf9e-4d38-46f4-9a6a-8a820426256b', id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', type: 'signal', index: 'myFakeSignalIndex', - depth: 2, + depth: 1, }, ]; expect(signal).toEqual(expected); }); - - test('it removes internal tags from a typical rule', () => { - const rule = getPartialRulesSchemaMock(); - rule.tags = [ - 'some fake tag 1', - 'some fake tag 2', - `${INTERNAL_RULE_ID_KEY}:rule-1`, - `${INTERNAL_IMMUTABLE_KEY}:true`, - ]; - const noInternals = removeInternalTagsFromRule(rule); - expect(noInternals).toEqual(getPartialRulesSchemaMock()); - }); - - test('it works with an empty array', () => { - const rule = getPartialRulesSchemaMock(); - rule.tags = []; - const noInternals = removeInternalTagsFromRule(rule); - const expected = getPartialRulesSchemaMock(); - expected.tags = []; - expect(noInternals).toEqual(expected); - }); - - test('it works if tags does not exist', () => { - const rule = getPartialRulesSchemaMock(); - delete rule.tags; - const noInternals = removeInternalTagsFromRule(rule); - const expected = getPartialRulesSchemaMock(); - delete expected.tags; - expect(noInternals).toEqual(expected); - }); - - test('it works if tags contains normal values and no internal values', () => { - const rule = getPartialRulesSchemaMock(); - const noInternals = removeInternalTagsFromRule(rule); - expect(noInternals).toEqual(rule); - }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts index e7098c015c165..78818779dd661 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts @@ -5,35 +5,41 @@ */ import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; -import { INTERNAL_IDENTIFIER } from '../../../../common/constants'; import { SignalSourceHit, Signal, Ancestor } from './types'; -export const buildAncestor = (doc: SignalSourceHit, rule: Partial): Ancestor => { - const existingSignal = doc._source.signal?.parent; - if (existingSignal != null) { +/** + * Takes a parent signal or event document and extracts the information needed for the corresponding entry in the child + * signal's `signal.parents` array. + * @param doc The parent signal or event + */ +export const buildParent = (doc: SignalSourceHit): Ancestor => { + if (doc._source.signal != null) { return { - rule: rule.id != null ? rule.id : '', + rule: doc._source.signal.rule.id, id: doc._id, type: 'signal', index: doc._index, - depth: existingSignal.depth + 1, + // We first look for signal.depth and use that if it exists. If it doesn't exist, this should be a pre-7.10 signal + // and should have signal.parent.depth instead. signal.parent.depth in this case is treated as equivalent to signal.depth. + depth: doc._source.signal.depth ?? doc._source.signal.parent?.depth ?? 1, }; } else { return { - rule: rule.id != null ? rule.id : '', id: doc._id, type: 'event', index: doc._index, - depth: 1, + depth: 0, }; } }; -export const buildAncestorsSignal = ( - doc: SignalSourceHit, - rule: Partial -): Signal['ancestors'] => { - const newAncestor = buildAncestor(doc, rule); +/** + * Takes a parent signal or event document with N ancestors and adds the parent document to the ancestry array, + * creating an array of N+1 ancestors. + * @param doc The parent signal/event for which to extend the ancestry. + */ +export const buildAncestors = (doc: SignalSourceHit): Ancestor[] => { + const newAncestor = buildParent(doc); const existingAncestors = doc._source.signal?.ancestors; if (existingAncestors != null) { return [...existingAncestors, newAncestor]; @@ -42,35 +48,33 @@ export const buildAncestorsSignal = ( } }; -export const buildSignal = (doc: SignalSourceHit, rule: Partial): Signal => { - const ruleWithoutInternalTags = removeInternalTagsFromRule(rule); - const parent = buildAncestor(doc, rule); - const ancestors = buildAncestorsSignal(doc, rule); - let signal: Signal = { - parent, +/** + * Builds the `signal.*` fields that are common across all signals. + * @param docs The parent signals/events of the new signal to be built. + * @param rule The rule that is generating the new signal. + */ +export const buildSignal = (docs: SignalSourceHit[], rule: Partial): Signal => { + const parents = docs.map(buildParent); + const depth = parents.reduce((acc, parent) => Math.max(parent.depth, acc), 0) + 1; + const ancestors = docs.reduce((acc: Ancestor[], doc) => acc.concat(buildAncestors(doc)), []); + return { + parents, ancestors, - original_time: doc._source['@timestamp'], status: 'open', - rule: ruleWithoutInternalTags, + rule, + depth, }; - if (doc._source.event != null) { - signal = { ...signal, original_event: doc._source.event }; - } - if (doc._source.threshold_count != null) { - signal = { ...signal, threshold_count: doc._source.threshold_count }; - delete doc._source.threshold_count; - } - return signal; }; -export const removeInternalTagsFromRule = (rule: Partial): Partial => { - if (rule.tags == null) { - return rule; - } else { - const ruleWithoutInternalTags: Partial = { - ...rule, - tags: rule.tags.filter((tag) => !tag.startsWith(INTERNAL_IDENTIFIER)), - }; - return ruleWithoutInternalTags; - } +/** + * Creates signal fields that are only available in the special case where a signal has only 1 parent signal/event. + * @param doc The parent signal/event of the new signal to be built. + */ +export const additionalSignalFields = (doc: SignalSourceHit) => { + return { + parent: buildParent(doc), + original_time: doc._source['@timestamp'], + original_event: doc._source.event ?? undefined, + threshold_count: doc._source.threshold_count ?? undefined, + }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index da17d4a1f123a..7ee157beec789 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -120,7 +120,6 @@ export const signalRulesAlertType = ({ enabled, schedule: { interval }, throttle, - params: ruleParams, } = savedObject.attributes; const updatedAt = savedObject.updated_at ?? ''; const refresh = actions.length ? 'wait_for' : false; @@ -343,7 +342,7 @@ export const signalRulesAlertType = ({ if (result.success) { if (actions.length) { const notificationRuleParams: NotificationRuleTypeParams = { - ...ruleParams, + ...params, name, id: savedObject.id, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts index 8b9fb0574efe9..41c825ea4d978 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -291,37 +291,7 @@ describe('singleBulkCreate', () => { test('filter duplicate rules will return nothing filtered when the two rule ids do not match with each other', () => { const filtered = filterDuplicateRules('some id', sampleDocWithAncestors()); - expect(filtered).toEqual([ - { - _index: 'myFakeSignalIndex', - _type: 'doc', - _score: 100, - _version: 1, - _id: 'e1e08ddc-5e37-49ff-a258-5393aa44435a', - _source: { - someKey: 'someValue', - '@timestamp': '2020-04-20T21:27:45+0000', - signal: { - parent: { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', - type: 'event', - index: 'myFakeSignalIndex', - depth: 1, - }, - ancestors: [ - { - rule: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - id: 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71', - type: 'event', - index: 'myFakeSignalIndex', - depth: 1, - }, - ], - }, - }, - }, - ]); + expect(filtered).toEqual(sampleDocWithAncestors().hits.hits); }); test('filters duplicate rules will return empty array when the two rule ids match each other', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts index 74709f31563ee..be71c67615a4c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts @@ -51,7 +51,10 @@ export const filterDuplicateRules = ( if (doc._source.signal == null) { return true; } else { - return !doc._source.signal.ancestors.some((ancestor) => ancestor.rule === ruleId); + return !( + doc._source.signal.ancestors.some((ancestor) => ancestor.rule === ruleId) || + doc._source.signal.rule.id === ruleId + ); } }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index aecdbe10695d2..700a8fb5022d7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -44,8 +44,16 @@ export interface SignalSource { [key: string]: SearchTypes; '@timestamp': string; signal?: { - parent: Ancestor; + // parent is deprecated: new signals should populate parents instead + // both are optional until all signals with parent are gone and we can safely remove it + parent?: Ancestor; + parents?: Ancestor[]; ancestors: Ancestor[]; + rule: { + id: string; + }; + // signal.depth doesn't exist on pre-7.10 signals + depth?: number; }; } @@ -113,7 +121,7 @@ export type SignalRuleAlertTypeDefinition = Omit & { }; export interface Ancestor { - rule: string; + rule?: string; id: string; type: string; index: string; @@ -122,12 +130,15 @@ export interface Ancestor { export interface Signal { rule: Partial; - parent: Ancestor; + // DEPRECATED: use parents instead of parent + parent?: Ancestor; + parents: Ancestor[]; ancestors: Ancestor[]; - original_time: string; + original_time?: string; original_event?: SearchTypes; status: Status; threshold_count?: SearchTypes; + depth: number; } export interface SignalHit { From 92ab49c1003a2b83f98f6be731f0386c1266ffdb Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Wed, 9 Sep 2020 14:42:47 -0400 Subject: [PATCH 13/25] [CI] Balance xpack ci groups a bit (#77068) --- x-pack/test/functional/apps/maps/index.js | 2 +- .../security_and_spaces/apis/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index ef8b4ad4c0f19..03b75601ec2a8 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -28,7 +28,7 @@ export default function ({ loadTestFile, getService }) { }); describe('', function () { - this.tags('ciGroup7'); + this.tags('ciGroup9'); loadTestFile(require.resolve('./documents_source')); loadTestFile(require.resolve('./blended_vector_layer')); loadTestFile(require.resolve('./vector_styling')); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts index 81ffc5eea9220..ed501b235a457 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts @@ -12,7 +12,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { const supertest = getService('supertest'); describe('saved objects security and spaces enabled', function () { - this.tags('ciGroup5'); + this.tags('ciGroup8'); before(async () => { await createUsersAndRoles(es, supertest); From 87ca6ff70c90144f010c1757f309aac8f53608ce Mon Sep 17 00:00:00 2001 From: John Schulz Date: Wed, 9 Sep 2020 14:55:21 -0400 Subject: [PATCH 14/25] First pass. Change TS type. Update OpenAPI (#76434) Co-authored-by: Elastic Machine --- .../common/openapi/spec_oas3.json | 53 +++++++++++++------ .../ingest_manager/common/types/models/epm.ts | 4 +- .../hooks/use_package_icon_type.ts | 2 +- .../epm/screens/detail/screenshots.tsx | 2 +- .../common/endpoint/generate_data.ts | 3 +- 5 files changed, 43 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json index d75a914e080d7..b7856e6d57402 100644 --- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -1425,11 +1425,13 @@ }, "icons": [ { - "src": "/package/coredns-1.0.1/img/icon.png", + "path": "/package/coredns-1.0.1/img/icon.png", + "src": "/img/icon.png", "size": "1800x1800" }, { - "src": "/package/coredns-1.0.1/img/icon.svg", + "path": "/package/coredns-1.0.1/img/icon.svg", + "src": "/img/icon.svg", "size": "255x144", "type": "image/svg+xml" } @@ -1704,7 +1706,8 @@ }, "icons": [ { - "src": "/package/endpoint/0.3.0/img/logo-endpoint-64-color.svg", + "path": "/package/endpoint/0.3.0/img/logo-endpoint-64-color.svg", + "src": "/img/logo-endpoint-64-color.svg", "size": "16x16", "type": "image/svg+xml" } @@ -2001,7 +2004,8 @@ "download": "/epr/aws/aws-0.0.3.tar.gz", "icons": [ { - "src": "/package/aws/0.0.3/img/logo_aws.svg", + "path": "/package/aws/0.0.3/img/logo_aws.svg", + "src": "/img/logo_aws.svg", "title": "logo aws", "size": "32x32", "type": "image/svg+xml" @@ -2019,7 +2023,8 @@ "download": "/epr/endpoint/endpoint-0.1.0.tar.gz", "icons": [ { - "src": "/package/endpoint/0.1.0/img/logo-endpoint-64-color.svg", + "path": "/package/endpoint/0.1.0/img/logo-endpoint-64-color.svg", + "src": "/img/logo-endpoint-64-color.svg", "size": "16x16", "type": "image/svg+xml" } @@ -2087,7 +2092,8 @@ "download": "/epr/log/log-0.9.0.tar.gz", "icons": [ { - "src": "/package/log/0.9.0/img/icon.svg", + "path": "/package/log/0.9.0/img/icon.svg", + "src": "/img/icon.svg", "type": "image/svg+xml" } ], @@ -2103,7 +2109,8 @@ "download": "/epr/longdocs/longdocs-1.0.4.tar.gz", "icons": [ { - "src": "/package/longdocs/1.0.4/img/icon.svg", + "path": "/package/longdocs/1.0.4/img/icon.svg", + "src": "/img/icon.svg", "type": "image/svg+xml" } ], @@ -2119,7 +2126,8 @@ "download": "/epr/metricsonly/metricsonly-2.0.1.tar.gz", "icons": [ { - "src": "/package/metricsonly/2.0.1/img/icon.svg", + "path": "/package/metricsonly/2.0.1/img/icon.svg", + "src": "/img/icon.svg", "type": "image/svg+xml" } ], @@ -2135,7 +2143,8 @@ "download": "/epr/multiversion/multiversion-1.1.0.tar.gz", "icons": [ { - "src": "/package/multiversion/1.1.0/img/icon.svg", + "path": "/package/multiversion/1.1.0/img/icon.svg", + "src": "/img/icon.svg", "type": "image/svg+xml" } ], @@ -2151,7 +2160,8 @@ "download": "/epr/mysql/mysql-0.1.0.tar.gz", "icons": [ { - "src": "/package/mysql/0.1.0/img/logo_mysql.svg", + "path": "/package/mysql/0.1.0/img/logo_mysql.svg", + "src": "/img/logo_mysql.svg", "title": "logo mysql", "size": "32x32", "type": "image/svg+xml" @@ -2169,7 +2179,8 @@ "download": "/epr/nginx/nginx-0.1.0.tar.gz", "icons": [ { - "src": "/package/nginx/0.1.0/img/logo_nginx.svg", + "path": "/package/nginx/0.1.0/img/logo_nginx.svg", + "src": "/img/logo_nginx.svg", "title": "logo nginx", "size": "32x32", "type": "image/svg+xml" @@ -2187,7 +2198,8 @@ "download": "/epr/redis/redis-0.1.0.tar.gz", "icons": [ { - "src": "/package/redis/0.1.0/img/logo_redis.svg", + "path": "/package/redis/0.1.0/img/logo_redis.svg", + "src": "/img/logo_redis.svg", "title": "logo redis", "size": "32x32", "type": "image/svg+xml" @@ -2205,7 +2217,8 @@ "download": "/epr/reference/reference-1.0.0.tar.gz", "icons": [ { - "src": "/package/reference/1.0.0/img/icon.svg", + "path": "/package/reference/1.0.0/img/icon.svg", + "src": "/img/icon.svg", "size": "32x32", "type": "image/svg+xml" } @@ -2222,7 +2235,8 @@ "download": "/epr/system/system-0.1.0.tar.gz", "icons": [ { - "src": "/package/system/0.1.0/img/system.svg", + "path": "/package/system/0.1.0/img/system.svg", + "src": "/img/system.svg", "title": "system", "size": "1000x1000", "type": "image/svg+xml" @@ -3913,11 +3927,20 @@ "src": { "type": "string" }, + "path": { + "type": "string" + }, "title": { "type": "string" + }, + "size": { + "type": "string" + }, + "type": { + "type": "string" } }, - "required": ["src"] + "required": ["src", "path"] } }, "icons": { diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index f083400997870..8bc5d9f7210b2 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -74,10 +74,8 @@ export interface RegistryPackage { } interface RegistryImage { - // https://github.com/elastic/package-registry/blob/master/util/package.go#L74 - // says src is potentially missing but I couldn't find any examples - // it seems like src should be required. How can you have an image with no reference to the content? src: string; + path: string; title?: string; size?: string; type?: string; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts index e5a7191372e9c..690ffdf46f704 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_package_icon_type.ts @@ -42,7 +42,7 @@ export const usePackageIconType = ({ const svgIcons = (paramIcons || iconList)?.filter( (iconDef) => iconDef.type === 'image/svg+xml' ); - const localIconSrc = Array.isArray(svgIcons) && svgIcons[0]?.src; + const localIconSrc = Array.isArray(svgIcons) && (svgIcons[0].path || svgIcons[0].src); if (localIconSrc) { CACHED_ICONS.set(pkgKey, toImage(localIconSrc)); setIconType(CACHED_ICONS.get(pkgKey) || ''); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx index d8388a71556d6..6326e9072be8e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx @@ -75,7 +75,7 @@ export function Screenshots(props: ScreenshotProps) { set image to same width. Will need to update if size changes. */} Date: Wed, 9 Sep 2020 12:53:51 -0700 Subject: [PATCH 15/25] [Enterprise Search] Add Overview landing page/plugin (#76734) * [public] Register Enterprise search plugin + move new Home strings to constants * [server] Register plugin access/visibility * Set up Enterprise Search Kibana Chrome - Add SetEnterpriseSearchChrome - Update Enterprise Search breadcrumbs to link to new overview plugin (+ update overview plugin URL per team discussion) - Add ability to break out of React Router basename by not using history.createhref - Update createHref mock to more closely match Kibana urls (adding /app prefix) - Minor documentation fix * Set up Enterprise Search plugin telemetry - client-side: SendEnterpriseSearchTelemetry - server-side: register saved objects, usage collector, etc. * Enterprise search overview views (#23) * Add formatTestSubj util This allows us to correctly format strings into our casing for data-test-subj attrs * Add images and stylesheet * Add product card component * Add index component * Remove unused styles * Fix inter-plugin links - by add shouldNotCreateHref prop to RR helpers - similiar to breadcrumb change * Fix/clean up CSS - Prefer EUI components over bespoke CSS (e.g. EuiCard) - Remove unused or unspecific CSS - Pull out product card CSS to its component - Fix kebab-cased CSS classes to camelCased * Clean up ProductCard props - Prefer passing in our plugin consts instead of separate props - Move productCardDescription to constants - Update tests * Add telemetry clicked actions to product buttons + revert data-test-subj strings to previous implementation + prune format_test_subj helper by using lodash util directly * [PR feedback] Add new plugin to applicationUsageSchema per telemetry team request * Fix failing functional navLinks test * Fix telemetry schema test * [Perf] Optimize assets size by switching from 300kb SVG to 25kb PNG * Only show product cards if the user has access to that product - adds access checks - fixes flex/CSS to show one card at a time Co-authored-by: Scotty Bollinger --- .../collectors/application_usage/schema.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 28 ++++++ .../enterprise_search/common/constants.ts | 30 +++++- .../enterprise_search/common/types/index.ts | 4 + .../__mocks__/react_router_history.mock.ts | 2 +- .../enterprise_search/assets/app_search.png | Bin 0 -> 25568 bytes .../assets/bg_enterprise_search.png | Bin 0 -> 24757 bytes .../assets/workplace_search.png | Bin 0 -> 30483 bytes .../components/product_card/index.ts | 7 ++ .../components/product_card/product_card.scss | 58 ++++++++++++ .../product_card/product_card.test.tsx | 57 ++++++++++++ .../components/product_card/product_card.tsx | 71 ++++++++++++++ .../applications/enterprise_search/index.scss | 54 +++++++++++ .../enterprise_search/index.test.tsx | 50 ++++++++++ .../applications/enterprise_search/index.tsx | 78 ++++++++++++++++ .../generate_breadcrumbs.test.ts | 54 +++++++---- .../kibana_chrome/generate_breadcrumbs.ts | 16 +++- .../shared/kibana_chrome/generate_title.ts | 2 +- .../shared/kibana_chrome/index.ts | 6 +- .../shared/kibana_chrome/set_chrome.test.tsx | 33 ++++++- .../shared/kibana_chrome/set_chrome.tsx | 26 +++++- .../react_router_helpers/eui_link.test.tsx | 10 +- .../shared/react_router_helpers/eui_link.tsx | 18 +++- .../applications/shared/telemetry/index.ts | 7 +- .../shared/telemetry/send_telemetry.test.tsx | 22 ++++- .../shared/telemetry/send_telemetry.tsx | 14 ++- .../enterprise_search/public/plugin.ts | 38 ++++---- .../enterprise_search/telemetry.test.ts | 85 +++++++++++++++++ .../collectors/enterprise_search/telemetry.ts | 87 ++++++++++++++++++ .../server/collectors/lib/telemetry.test.ts | 2 +- .../enterprise_search/server/plugin.ts | 18 +++- .../routes/enterprise_search/telemetry.ts | 3 +- .../enterprise_search/telemetry.ts | 19 ++++ .../schema/xpack_plugins.json | 21 +++++ .../security_only/tests/nav_links.ts | 8 +- 35 files changed, 868 insertions(+), 61 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/app_search.png create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/bg_enterprise_search.png create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/workplace_search.png create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx create mode 100644 x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/saved_objects/enterprise_search/telemetry.ts diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 6efe872553583..2e79cdaa7fc6b 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -66,6 +66,7 @@ export const applicationUsageSchema = { csm: commonSchema, canvas: commonSchema, dashboard_mode: commonSchema, // It's a forward app so we'll likely never report it + enterpriseSearch: commonSchema, appSearch: commonSchema, workplaceSearch: commonSchema, graph: commonSchema, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index acd575badbe5b..5bce03a292760 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -414,6 +414,34 @@ } } }, + "enterpriseSearch": { + "properties": { + "clicks_total": { + "type": "long" + }, + "clicks_7_days": { + "type": "long" + }, + "clicks_30_days": { + "type": "long" + }, + "clicks_90_days": { + "type": "long" + }, + "minutes_on_screen_total": { + "type": "float" + }, + "minutes_on_screen_7_days": { + "type": "float" + }, + "minutes_on_screen_30_days": { + "type": "float" + }, + "minutes_on_screen_90_days": { + "type": "float" + } + } + }, "appSearch": { "properties": { "clicks_total": { diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 05d27d7337a6e..6e2f0c0f24b7a 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -11,7 +11,24 @@ export const ENTERPRISE_SEARCH_PLUGIN = { NAME: i18n.translate('xpack.enterpriseSearch.productName', { defaultMessage: 'Enterprise Search', }), - URL: '/app/enterprise_search', + NAV_TITLE: i18n.translate('xpack.enterpriseSearch.navTitle', { + defaultMessage: 'Overview', + }), + SUBTITLE: i18n.translate('xpack.enterpriseSearch.featureCatalogue.subtitle', { + defaultMessage: 'Search everything', + }), + DESCRIPTIONS: [ + i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription1', { + defaultMessage: 'Build a powerful search experience.', + }), + i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription2', { + defaultMessage: 'Connect your users to relevant data.', + }), + i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription3', { + defaultMessage: 'Unify your team content.', + }), + ], + URL: '/app/enterprise_search/overview', }; export const APP_SEARCH_PLUGIN = { @@ -23,6 +40,10 @@ export const APP_SEARCH_PLUGIN = { defaultMessage: 'Leverage dashboards, analytics, and APIs for advanced application search made simple.', }), + CARD_DESCRIPTION: i18n.translate('xpack.enterpriseSearch.appSearch.productCardDescription', { + defaultMessage: + 'Elastic App Search provides user-friendly tools to design and deploy a powerful search to your websites or web/mobile applications.', + }), URL: '/app/enterprise_search/app_search', SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/app-search/', }; @@ -36,6 +57,13 @@ export const WORKPLACE_SEARCH_PLUGIN = { defaultMessage: 'Search all documents, files, and sources available across your virtual workplace.', }), + CARD_DESCRIPTION: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.productCardDescription', + { + defaultMessage: + "Unify all your team's content in one place, with instant connectivity to popular productivity and collaboration tools.", + } + ), URL: '/app/enterprise_search/workplace_search', SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/workplace-search/', }; diff --git a/x-pack/plugins/enterprise_search/common/types/index.ts b/x-pack/plugins/enterprise_search/common/types/index.ts index a41a42da477ee..d5774adc0d516 100644 --- a/x-pack/plugins/enterprise_search/common/types/index.ts +++ b/x-pack/plugins/enterprise_search/common/types/index.ts @@ -18,6 +18,10 @@ export interface IInitialAppData { ilmEnabled?: boolean; isFederatedAuth?: boolean; configuredLimits?: IConfiguredLimits; + access?: { + hasAppSearchAccess: boolean; + hasWorkplaceSearchAccess: boolean; + }; appSearch?: IAppSearchAccount; workplaceSearch?: IWorkplaceSearchInitialData; } diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts index 779eb1a043e8c..842dcefd3aef8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts @@ -9,7 +9,7 @@ * Jest to accept its use within a jest.mock() */ export const mockHistory = { - createHref: jest.fn(({ pathname }) => `/enterprise_search${pathname}`), + createHref: jest.fn(({ pathname }) => `/app/enterprise_search${pathname}`), push: jest.fn(), location: { pathname: '/current-path', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/app_search.png b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/app_search.png new file mode 100644 index 0000000000000000000000000000000000000000..6cf0639167e2fe45a58adfebe78c4864ac336e3a GIT binary patch literal 25568 zcmb5VbyQT}7dL!|3zwQ91c{*rL`pgY0b%Hn4neG;Bo#zT${D&FL_$DH0VyfTL6DG6 zL23{Y6p)Zo>KVVkzu)yfo%1=n&)#Rx+;wl1k%1P13P}Y3076Gw-2?zgApjsr zqyUMCf=grqadK*;XQl!844j{z1J2DSCugT;=YV%V;ME6sbkWk%0`47vXAkhGm64GV zaA}~Tq5>QnfQL=Y%*=p8Enr^>2t5WIYXG})z&r&ojs=XO0pk@w$m`_f1TY8(tV^h( zW&y)U;EFFG{t~#G2M7lPQgwi2Ibe|nh$a9sU4Uf8Sa3ui1 z6ai8-fN&Tfo(D)(0TOS3J1+sT=YUK%aJ6r9>o*`0_3h{0&&~bA!=v53zf0eD0qJHw z0Z~Bg#ov=tK>XFOokPH`=C7wg;MdW!kBoo*o*n!-1*F>!kB-?;mjLmk<&|G6Keu;x ze*>lo8+(Uu+-_4E%b z*;OlJvXt*7y{T#bG&~WA3pFyoRa4gx8X5B*-xHNLlvF}^k(0ah?YnnU=g9aJDEnGm z`f5ta2s{f8 z-8aqiH@)jvHS@d!u+N)z*mPQFzW`(b z89|`?WbSpJ+v$ML7$(drsn`{gSnT=qC}gN}+2x07%IG=P>AX0-y!@0I05C6g)K$!U zzidpXAn@XFpxRlzn+tywr@C9}S)+19ESUd?kI~_s0}c%TXZ$+M@}~>|W=VCG0Gk#h zUMzL~jU4}tB8iubGUA1$|KI56zY&!zaU@8*1ReZhk#$B<;kO;J!DE*%B;TlgP9S}J zgG{<&3{_hA4bm3)N@6tTK_ZF{S$o28^^0(pt7~PBqwVm(VQX5$V}VVtCoLVufmMj^$I=K79bczy z-P(LPvOG`x4!#^cyeK?WK82g;MF)KyaOTjCgMZ`y{|ESZsu(2`b2B3GCPYRX9+Df} z;Gd`{3gIv!`~Te{a3=+Zh5%N;4oLj}9AHfp+vQx!L>+RK3?}tFYu+zxuTyShpSA4S z*J*8+zJrZRiEkcNZW^@1#R*&Xgx-4=lb0L_c4e}lVcZ{OmuDnBdmFF5I5xDu^)`bt z*Ka(@rI?0TtaE~&w}V=HuYP)ogM(E+SS{b)T!&ji1Do%6x!n~X){Pnrpr5z}Qcl+( z-2013OBNV@T^o|z-dtb({hMpCz1EhKsyO$gtiAXdV!9e}V!L(6Dm(mxxN8;L-I=&6 z=XlpS>QhQec8SPH0)kPNib{y$B0W9h7Bge>wq^m2DNbcJOr+3`{=yfyUJMJ>C3Vji z4D*a;iv0XCQZGhzN+0G790sI3Ss&Q-|65$B$LD-SbyiEXHt_RbxvkHz<*#;1-_F!T zza7MpIuW)OPnVHc=|qwE{C1lt$m6FxM)5qHcMKo*@J`pei!$P+Bq&7r3m&8Z&*N_{ zPDr^L8kXnKZfwl8_^Op(t+VVaqaH{)3Qt`%6LK`Nmt7hWX-4L0;8(4D&CJdT)xG0| zx0I=pT08SUypne1c|+o4RL^4sMZwPMLINY;gnFVOjS5%(Xju?5%B~P|Mk+1-6fwvB zwsP(@siKnpRSR0tDXch8)CHgrCw!q0Hku!*Z-8O&@2CAPAfdi$7c4DKhA^XHpmv?} zEK_ZK>MV}p^ldQ$RpIQj$2$xB%pA#rdF#h_+o_#Uwq9`hpZ3uM^4StC`U2q>EcGPd zP#&(&g~0RU*p2O~>vX`%SzV{{!3OuNLK0-d(F@58`I&eGA_HHyr2%hP06PXnvUW7(O~G|Dzw-hbgElV`xUu; zhnl*g$a=EtWx{nsyeMlg{lOCwl_%GqA6}mP6d2cGY{wFNHBPfbG!Dd$D$Z# zs)nH8Rwy50|JCRp_=M(Cxm8ZQclY}H%y0XVP{92vMxT36F857%TUrg922R$@@#a#s zI6haZyU&P1*u@xuUi5;JuTtT5sh?_oH(i^}PPsq=&3olz=6wJ{ z&m7dc_K41z;P)D4t-Vw3^7qMiCE;62AATz-6$l2~Eyced9^NVaX>~#DR^}U_%n!@^ z>oN!P#FzPuyG8QGY- z+(?@QwJ{(R<6JNXBf{lFURA|k<163rbq97lkujGfOXIgJ7NV>W+Lwvy;aJ$cGkGHU zPLj;ebUS{Ykx?KKk~GFarRLRhW|33^(Rb^UmRNd!#KZ@8LD!iOJQTMXABMCTSj2EwA%}Ei7SshgiPhC4)1CSZ7 zA(lfq63HkC^7Ja*^+#-bY9F~{#N^s2*MeWAZi=*H`e2Pf|B?%~qnPP^@mPot?DPrV(KieXBu1wz0W znkl9T#&pVDE8BW4Pc4;vI>?tX8;#5m{}UOKkh}2y_byKoS`U2o>oo7wlpm%I-de@~ znrHUDFLz<;(XQGm^(gxA+pGSf_IfsHah=^8Iu$rpsvJ{etaKHV5ZU`hb}``*bZnn9 zs)GY!4HB;5g}3K9)qaBc8~fHqhbX@|^UP4lF}II;u6 zpd-qL3aPz#{LKan0VDYH;r1ysH>os$g>E7!rNz64LBS3Z>Ft#33KK~Tk;`(NoUr4= z3Cu4T->tg7OmoD#j!X+aN=By6%i^xFu?F9@>I5uaFFX`Q6VhoG+u`|cz`MbJW+dMl z+q~GbP1^4EoeN;i(0vAJ`csBl{OgDC0Vy!niz@{}fmry!$7c@M9G0v9x+O+4+yRSV zL49Kp6ZJOI(3< z*V}a{t^Jd`m%$7A*K}wR+psu>s4vW0lE6*GPeK}UU|d9HdAa-Ws!O+7EiVyKy^d4c zLv#POLG+OY#29D4dHhfQ!Fy)IFTczok{sCO`(`^5eEKWVk0yEn$6mZg<6-L%dPVMJ zG-<+#A!%-?O%+0`LFt{X(@uv{c#aak$JvnvUo+Zxz2~w)S8&yJqgB zTF89w$wh}$-OHIR!p_c5HEUKL7Ma}ZkBog7u=vleDu+}Dnd7C z^$k*2QjG_wOW8hB{);eoMSSqqm-zzhPhlQ&3Ju#4*6qN5zfzj#VjMQd^`Q@<;PUK| z)<3Wu z_&4*0S|W0DhA;X3#{#z;LK7a!mj!V_R&TGL2x1pE=9FW`G3@xqK_jCdTV0^OD~-%3 zxk$C8KGF7kvAoWC!qvoB-1Sqqr452n{tH_hnez61?*wV2o zI4IXS9cOyR^-%AaH(~sDYsvNNLulx@5AZ^_=M+16_Wn3~D;?(p#!4lAY?G`~SxI z|DOpxa79j2Xdz$_yVq5sO~opz_X+F!?DCxyNsR9B>BfWmXD_70Sq0sj4=7K}$mx{_ z%(R;lb;1@I*m;KVUpOUECT<_rn@gqNmiX?O*Thg*O`6HN`~qE`K4Tr81r0j z$zZ9={^XZQTE`8Q^XWf^0hjtqM;s-?Cdp;ISTPUuqtPIUnRyJwcZo@E8wI#1y^ML^ z$k;$hQoY`c4Paf%lTg`J5pgxC8^v;eeJA?03*B{(Ro9%iyAb#mTyb|0hlt?>z%Jx` zL2b0r$9uHZ363x`_y(+%YUGZ-%D8vFYKa(5bP4_q&nm~nDw(*l2vJz&Q(f^-~OeO)}qN|t=GtAE3-NA~7?@(5i%YjDXDyd`bFc2RyF+6Z&y!F~C-~S7m*{O}TU$ zOa~nZ+H<$wH+!#^y=CofJ+L5;+sJ@5>pBa8%scgMQrc|;pU2^`z?aBDrLPn956Mfy zc)`1-C{Bb9Sy}cJ( zZ+{#=#Y}JZ)M{c1H=MjMUY%2P_E{a0qz#=~aTk>15mXxV#<_70c8lE`;9aP3jN6PsgKhm(%^vKnpK z&-$M~k!djd(C*l?OgYe@`?H|In(hWpUu%`rk|rB>>G3MtI=nEb7hBOod%gKYIq};n z>zZt;o1}a2nx~;MK@-9!lT>DwF%sIHA?9D5p*d?N+qTVQFOD?+|mL2(|Tm^ zF@(Qqnp);e#YQiZF;Yz>tT2Qh?ahV-^8dMzc%%c}Z0iHyNpe}uFd64*8XH8a$3=$0 z5@35QuR{w!L{vnLSA9a)EHszFTZ}qQGkJz6lp>Tk6!0DdID3qq8`a+36=(DroDrJz z`#rHR0POikT5hSnnl?7{mx&yGO0BIn+3`jt6NkJm#>c8%qTR;)nB=0fDw`!I(Z{6A z;GgF~R@fZL#9MzTIeyB8=^G-Y)BQaE?xoMNiBTth$$oVg@D`T?T_Rq>MD`D*j6ACj zTO`!a&avm@#Bx=V-2RMoSAK|p)Qn^8{@G%2F6IthJt<3|*xwplU0ve(1c>;0P1~tH zIJJH^^D`BDN=rOpP9G3u~SdwxZGG5KgMO1$#U?@uYYdW$FDoRXw1SSbR=4~zJFDp)mkkKGWi z`oKSq1Hw>4P#VsTf7`EI2%R325G3%tzdH5I{I!!?5Asi8SVgSf59f}wRo!RrtM}a8 z00#oK!r@kdGz;XeXt=hPJ7TVN$d{RwIk6JnG$%w|pcY|^=_pbQ#9dgh!n{Az7O+O; zJSttj%;ziNq^%cgEf2Y?DPmsE%XeL!Y{+WRr*36&lHcC z;R0-rA>V8q`^5o#K?}*oxhm1g9#%|YAWN?Xo=eERHU);8JSz0zazMTNz}o0SS~X89 zynZ@|P42ZT8aw0{q4~t$8lrOzWW6|qzk8j-Xly@MAO8p%0d(x8br?VMzs2tr@{>!w ziKKIfyt^gAX38Tkty^#ctG`LQI<8OmRc5TWf}p5K(u~e{NTX6Q+%S{VbO>LOVT(?G zE_;olw;%0i6^VcJ<63j3naQoQ4?Iq`CdMMLca)`O7f6Tj@fkK~gBx4tW;i`)+dB3~ zB_mxegQCOpgepCMvJYxb*9FJ#*Nc|+Nvyz66unsz4~6_!N)B(gehJdelW1*3Us)Es zQpgM<#dEAg({85J1nFHK!VhIwqx^TyRdEFa_a3%tp(baP?AL#&F?*X-fa6XLDd~k& z%ROeg5K*S|0jp7RO)d@liXEZwB=`sQ^hNN;l@}K=^7<){`0cZCR77{s@zHjKuV+Py zXPpgX@g9;!HZO6^2^KLo{CefU{hmb@xd&M|W?ECLYv47ZNZ75m7GM9T)sh$qmbpT< zkOdm>yG67|oUFgpHlZVgi^VdhCRQph(3ZfGQE~Pvpe8ew*vYtb|7~LzHd~rAL9Cm3 zx27!X7<}(@eJe0yu)<;aW}@nV1Gt?dE?ZC-yK14#m0v*upivZaCT+U zTzBYK{qkxyZX?Y-K&Bp#gqpe_^m~fbYqjALAW+^Lzap$V0-x#*G?>5Zoo< z_vVtKpd^cLkT_h?Asfd`-i3Z4m1w*J?_F#<-`(9@ce4Of5@ap7_!*vxpNg{*lAp=G z5QaWYA}Wq&vidOP1JB{?Z;u;T%37IvupUe8nc_RC0o?US3-RB^`+z8m)V9KhBmuGHuG>b(C`LCzoir2%r_$Dz0 zO%z6g8#zs`kTaWYu@EX*Xg zK2~5&;ZhYxDN;Z8=G|81-GbD39u~YJ$NAR;HwLxN>O@#!-kXm~GPhxt@!T&oFI_Cr#flepiO+F6)yIw0 zg3}e^P z{gG8}`oIP~Q^}ri{_GQWq9~uHOMK7x6F~Y%UC-E!eBShP!M#sFGM%L+;XTsTVtNw) z)HETyE1tvUBY&Z~E$-Q2t*L@MZ&7oC&1*ZuxdGs6%!SVJTmyQ^qlt3R97N zkX;lz28$u);Q2D{hc=;VcRkN)JvbOK!7Ahnuq+>B3Qv2@ezrQ(1pNcp6@~ zcm;`oZ%dNWfiwSOutT>T6s(AOg#Z$X*KM#Y zmNd+u#^YVMK(vWTB<}YAWJqlLeI!Mbki3e-&HjhPi4*YP!TG3BEZ5GO9IHhe9~%CaU3S|E$4 zpn~fV!!ams6#GO$zsyNibm348fKE@Le@2pM-g`=c#9<6KelIm z4h+IQSWNzeX0H8f^Caj~;y(i@z>iN2jD6U(B1UoiOQuJEDsO}q!3EDhp|gG)!cCpv z8=W;mRThhX#KH@uAsW}9>fr;-@0wxvf6M@A!bMgQhGW{=GN)}|$uwb=yM8?)f4Gu0 zd5nd{{p<>-6O{Iv8jAs9}TpIM!O{M5jaP)mv{Q_;6@eJfMwW zIaC`7GfuFWvtlt)?~`)>c&(if$@DxC*Xgp*Q!{R0Pe}eP3cskx#%zPKfOoZv2=ie+ z!uKkIyp(DGX1CkOU69z`MUGzlaLh8jS@7p5jD?sd77{S)r79s(GxNcKW21y8!u>b! z__o1zT9(XT*97I-&`)GiSbE98BE3F3x{}w2lI}`!5nCaO;_eHpi`4_WFK>h{eqBml zie%{p!7De>`c2Pqce7@5S&zflI^Rk|LlW~+C>snl-{0u!``NbdlM-2aWkD)wE{g%Q z2aikcy(f<(d5a|6=V;wN;VR1DT2AmPZ(qH3cj;BqE_55KkzI-dVde*MORHTW^#(71`v7+I2vBWH|k{~q2Kb(!1 zamf2-Sh66H;fQgoyMBM0$5|BV87E-T39TXDmjw zqgCH(!>j7mAihsVPEY4``g8HW)4$eP{apm;z#3u=0wj>G5wT%<%5%ut0qMUTC4XKk z7*!8>b|${o4R7SP>W^dD=C?95q@ak=v_bO_OB>yT<&?3-Pbz8%hAm3dzC6-BS%E4z zsm$M;w|C@&4A}9e`K@yK) z^irP5AT4PB4c^t0N)KAHSnfdRm$;n2G@W-o;0;&@?;o|j^OGA_9I+Cx@QK1@OQPQI zJ!kdZu}OkCHkfFg@-JlS{+RN_Kj1eG#D;#o2H@ zlEsKOSsW$EjmD$v?v4BCC3^Di#sx@K>TqRU3|(LG%h# zIYY@YV4B1Jm*c}N@dNs)n-}lZ7?X>SOHRFLt4vEMqqp3Vm{(AjA3xl>R2hk@+k!5T zC%@!{N2a%(P~wEa?J4u8-T{_DVU@dO9;dfj>L1MFe%@=1eOxU5SR z|9>`$t+yCy!@BD{%4QBx#ey0uv@pa+^mZ!_^G7A z-V8m6KF_$h5Ny!}gwJCWVL!s!_g(<06p0_uUjgCD9HH8XH$rA_*uyHPyKn`%Wb^6; zFMgSQry58Q9AKO`JC`qxWwB%f4GHu#)CtY3jwj>uum(nLLt7s?KP5j)4-tDJMfnE`(a0kG z;Hc7{KLsYxpC`kqKt-e~X&05NTl@6Xv<=zbsGbGaC1`sS!ssic+g?>D{i7B~Roqly zXw~uBi|NQgWXpLhi>5A5#xo2~K@kZ;yO7EwKC9kkqQz5nY}x{in;qFOnIUOpj^?;T zPuw4GOetBZ!tTVuEf-1lr?~G1b54daWR@p)VLPILE=%u4(myYpxMiin`sl%4Z}6w$ z_%9#5Nt0y~3l8T?lhmvkM@_Q3YEbwu?Hf;zpIf8&8ApgFhD74xHX(vbM~DU##1dFH zn(YS7dEYiIaj+tknKn!wkXZblE6elxZhsZSW@Q8PpH~@j$FNM#Jwmsp*$(fSr9juML(5;#`_R;Avx;amQ}dCS0Bz zSeCp_N?!w{^Sak8}57zN4y)?MM+ThKSwX#*Zezy{z3&l}l% zxm&IY@cp+s2e(^5wBXXuAD{=&@+AM%6C4P#{tm-^w7~xzLPhM^*ON`(StPD+vS9Aov*>ZPAxvA!nI->Ksi9S$umrz5t_TtqA9?D$hAlT!h2F8f4dfD>7h;hYChR zW%+vMwSi`FW@8jA1lZts`Gqx;d=bs$DJV9FV&dt?I~TxMKmyYUf^-p)mqdXhTb^SG z)*jilvtANsc@c9#9;1AR-9$mM*dM`eCR;TFn|s8mdeG7dYv;#3LfrYp@ig_j0TOSl zE{*lDs+lN)?l6B(LOWd2!ipMrtYKTFlr;0&-8ZP$M|^Bkwj5DbMgpF@`41XDI3PkV z&y;?oP?e#?+2M;d<1s39YS}q;E>6Pltz&9!ykHBRlJ`fSp= z+*rUS+jvZSZDmNbpJbL~Z3=js9*Vk4+t353~Q3d}Ef!$5Xz zT9)ydw-4V$gA5_AkVWDB2Gf)YIQ2nO71B{LQu3l31Pe(-}SH_&zFA*&HbLe)1SqE@i4Rb@6O$if?D4K4yuuN0;Nn}2AKsN?4JKd zit}KaOUb^HndGmBvI6x@y{*;jUsZi3ElqH3c{Y=imnQY`3o{c~-}awG!sVT{+KeT^ZppH=t96Y z;Zm}sV8fO42t_y75fAC+;Z{Cc2H8`+j6Bi=+bj|}~>#~WIU>hL9_tA78EL*U|1`xr z_Rx^A(pIguK-D(Iq=JK2Gbx5g=Sne;P{8B9dZ_8Kq*9&HmxClMvdMSR5=M2Uz z?5cqz9}ByRSuXM}NxpYw`YiHZc((n;9&*}HwrU<;I}bGIC|q$rHddf@FF?)({LN&S zM*1F{sdz~@m$bC~F7-Pkbo(rvZ&q}|&f}riMGy;Lbz+fiSvIu8&gaD!Jx#k(hz@5; zv){D7rk8(nHlOQwBq0MbKGXX3jPT;JU@CXW^^B$WxI1DP7XGRW*Y=zzoUszG3SWzm z8ebdCxCi?G*{vCN^(47cFTH&tx`QuhE;Gk5hp6l)xM&Y|oHQ^A+c%LB2bH8;*K zA_6K@CU{>gb;)rpzjv;2?R_`>DEXtHKS#PTD~mvfOe_^xN351@SsC|$5V1IX6Pw(A zvk%$HQR}%WGOv4u|eDxJBtJizq zC0F*PyzhH(^*j)9$FBZTAxVIOCz72nR|exJoTYD<{7Q7^TgGQ?4wKx=#_4IUx0^A? z<=%nuS%HbthTjiXD;&j#QGat_ zNUyqBv$sjtaS{%FXSH1Zt#21!5fVN?9?@5L<&^1y<;T5OBeQf*bCl;@2n-4iv9khn zVM~ElpV)CyuJ82aLW+`9-<|4Gdg#N*5Kc)C1T>`?6T(T}e_3u}Lk(W`X6F&BnbuKU z*XJ8O7ETy^A`{mBX9+=?4vC&`iG@g!W?OPK$%xZwnyVpKnS@Pd@-J(QY73bfu}ddm z(UG4{KP(4@71T4%BefGX*C;=V>2|1IMwe4WL~9=~#7=4pGP6L$Bf2t&``OL z*gyQlCz+)4g)}(-%dy8Wl0q2aD?qq_D5v#q&QSxS^>R~QNWc7mMIC9%$h=##1B*`S zNw~*o{rhtQqe^zVOe}S0>mn}H%NWhPc#te`TN>SCTqh1ETY?uGjnG4dxQ&HtpBcWk zc*^e7;S$I28Y*}#(1lFc)g`Y~($rS0t~+^mRUC*C^cTu>W%?kn_7TR-So0xEaDEJZ zKgv2F`=gO;O$t3e6yubY9bTOCfMxZ<-#Jo-NFe#mcHvhAxSB3_t_9wvad$>(cIjTt zsi6N|d6yc+%Pwe4GqRKzOmCD=UNCkjRQv zW0*+)AXnL{Z5yyU36;lUG43Q)q3ZWeM){7TeMD3cv+x zjUX+?UHlaYu17#eR`|ELEyI6Qc!gq^Rtdp7%;$-_!=mVaocQJvT)P+5y?eW|ZcNjb z=NvQ`gn0Xmg6&j2P9XkLX}+3r@aotp)6=yv^N|i2il$n%eA61nrdkfG@s3*Q+s~~= zih?ugD&Au@z8jI&kEWp+VocNoUfqg*9uT3VL1mI^SreR`y?Lm$tJ6gO9=$NUb;d6k z35zPj$@Q?h#=2NlsZcx25fC9-f!Pvk4%wXx1Y`tF$dg`=3Vm|G1_)zm^oD=40Ihfp z!aDgsOiXu8$fssz=1n{a2q7VPoi`%Zm_hNmuy$F2qcjS4<4pneT9y^a&V_=EB4JO< zU?SWJO|gb|45ivl-`X}!FhIZAdy3$6OU=lQaFzGg01^NhOyL5{K_T9A15SVnJdX~% zKvbtXCnK2aiJJe|wLTKXg~1Tyf{YN2qAT=}=REO2L#!Yl7WVAJ-CUY6QuKd3)`V~U z%0p)>I-O@~duKJ4)h}^7d)=Kw z6wjS4o9CtppUaBI(YoYbKFYOw{-%97t+n&}O@oQgZo4g}+**-1?cX6d7T8W2BgAvD zO^kx^8g2&ONS+yw6LaB8`#4p(xfm^}^eL*eZ!f*tl^fz2q=g=Jn})cDw6en!Eob1p z-S`_`o0p~2nsL7t2PXo$*CZ0v;#($6%Hs1#m8# zo%DnyWW7lP1Oc2YJmBxH=*x=^)1)gsuPqqgy$`Id`!bh91||vpQNqc0Rvwj`XljeJ z>*994{}i-AhdZMl2Pr`&i)SNx%!0Ha)d{!gRVa0S^TBU)AF~Z+#>jTGU#4TH^A_LHgm1qrEV`YS{#sYznRce_Mi9#bR_Kh1s`OSSU?ZL z@K~hPe%5aK=WR}>hU;u!l&Uw4eWrh0*8dJL3kvZcn`O*95T2_@+v=T2*0NW-)k=5ko>Dc0+01O^~S<#lw-v) zh_?eyXe7x;@oiU5+Zu_yc#9a@JgZM5&d#jdle*W4dDFX!SksX~9l!YT@(7ocZm@B6 zN3ObW_~u9x9+0bE!m}mYu6%z+1^WDIu}C**??9qs=tNX0OJUQ=(Guw5$bC5-XF0{d z=kA(kC7%3aV>Se4OVBHzhC;%9kVWpW4HwfUjXLSoK*Mo1EU!iH4J@_$wVoM?Torts zYCBZ65}GQxAY_BS3J;w`>l}hzjKs9}unLv!CikkP80BecOAB_Iy=+aOv?1Ja!O%(& z)1(gBtAF~6McYnkV^qr+KJi^bb1*0mLoM#!ev%ga#r!R!nb3c`!UaXQNTZ|<$Ke#I zy0K@rlo9aR6VG+f(+(=D0E1TAt)37#m-98^;pBtSduJRB)M<-{=g^W z=+(BdmgwGKN)~x6sp{8<6qX3k2A3#W7n&rjj<3o8P#uu|Ljrq+cfv%&^G%}TA9(h1 zu}IP5fG*znt@GNpM2}Yw8hd=&o9FH>Qw@Bc>_8GKlMTFnIH%b%jG%u>8~rf__a38K zp$D9?wNkmKdazs;L_iWb4b{sQ%dd@p^-@HimNZIz650z1z5u6+F2!}BPzs2-AQLfn z^`j(p5WMu^T7}Z!^&q2&$V9K*SFmVB@k@vahi404I_C*MK92E}(XWLXRaF;!^>6XnQ;%@FK;2Yn4+bZS7R=`+O{`9IS#jvp9aG`339xJ!AlNMj*oeMqqm>f#g@<%_Rp zPqLPf7-0N%3ryo=2}Hj>oU$q(5VMMLUbDp6K0W*JYShDX*hhF?I#wf@+~gY89Tm8Uj8@8J(t+v&Oos|;3N zVqG^uDVM3Z0hp!de&%#5Jd%#*L(~X*F+$l9(<{H|!lgp=_{0T0gYcSN6joP-kOcl} zgGJrSzOVT#Upo7b*v6YRC%~_K9~*Ke7ULTZFxP8V0^Ob`q~wJ^5d*@HN!$80l%fKr-g{veQ&uWH^qQ7+IlI_W zf76>z>OFecl&X>D{B^Lf?fwXQNCvHru6R3yEfLbZR-3qGd!?)GHP3CWEj5m)`nYQQ z=E9G&)0dy&)b45&bNy_z4=desLuM?k%l6**;XZ14JFTIyhu;P38ToS#jCcR*u5yhQ z+1mhZ`>8iqx4nVQKha~m-BwwrI*ErQlbA|zzuw?V>&#@PuzWvzA9IXLkxWV@mg=0+ z+5C0NTln^##N*d|O}0ld!THIrFB^KNls$aJ#CHTVq+jOyQV?b`e_Tu7Jf=>!_iLM! z@Vo@KN)J<)_-kAa3vom<%e(>vt2iicMbV%by~RG@D7jDh;Wu8GE&ZW4U(&5&UgC8o zJbKFfdgCA%Fw`)>{CT;xo_{gVLdDVlE~2IG;UdjuUK#8$_erd$lPUPJ;#SlJlz4sg zwrMKe2O-r+3VwBv$GM@I+xpTJ@Zqb!#P;K|%cZbBKx6j;ivH55bYHH15?v#YB)z5y z))-|SDvztX0hGHCb}d+mNt6LcM21RskEZ6YpB;B$dg@0X{NAFrEn#}=${#wDYo*RI z^G4&cT-|G~k0V&^D8{>wtNB%VTt)cW7W65s+hLIuzbjz!WN=LY;i^U>@h_{XieQ2W zVrgUu>E+}Qy?vwcRNn1%mxD*UX&z3|8Fh)0*9AKt>Ha4fPS33#dhIj%<}!?1l$68~ z!|zg-2OB{XABaCWT6~M`dm-5CYE<xNb2n{ln!2~aQ(nMN28@WH#2v>o@ zsrbx6#&;yOoERaVJ2Q#gJfV?QiAcI8k~}26cqwkHPK;;=Ufl`*P%*}`rgR~To%bClauo|$1uMnvj0q-G6(hZ9icBaS zy{fvL1QNB$u6*0AcDCIQx$Qa3qnyh7Iqj7gcw3z`+^zNE9i44?OCNI!_bAr6*V6?* zQh35|AtfJ5m#U7D#4@sT}zvg)`vb0SKvI7WY# z3O;*>LU??~)73ZkZs)JPrzhtrP_fpnh;uiup-ItxwLwNRatBcGEN0S#$-G$i)|{B^ z^+JfIhJbtQi2gE#uiU3qRwC#l0`5_$X`QnN(A-tq)$5ee27B^t<(rE9Im#)+e^6?^ zrlRpymF(jRnB}oS<@Y(SQ~1TwwP&*DcMKnRxIE2tBk2CmRx5CfX3o2Lr|h%<_v1TR zH78Qlt;ju@Bw5Bi?sn$aXtHxJ+vkwMV=&>$ix)X7)4WChOp}wL3wgs#Y3u1q7h&R< zx3CH4lDorxFU65yn4ECs&67c)!@T!h%zqk_JO8J~7$m_ln(;QAHb$~1s)B=m?|2E% z;6;?m zf*UIXmw*Y>{qn*(I+%JeKEa?AIC;6CE`}If_gIt4K;GUX`CA zjOxG&Pej3e%ezL3oYl>_{nXxsE)U~f6o$CQ=W>>1Kkm%i_INMxmQyI7&L7|VLb*I{ z5PCWtG#BOFn2r>vfYB?uk0S9-)NA-`(C#7XYxkN!GMbTW!#Co}EjEFBY;+yq5z=V+ zVRYsrMPwC_PPW&wM>dMLc5ATJx`mIwq~a+egUus~sj8RGJ6i@Tq!S(SugYoWd=cd? z^Z1U};B^DK+7_I*R>>9*&`RLcMxd?2Wb+xVJEXv&O7@%?9=+;i54w|Io_ z{PWHJGgc{L0Fhz#*ZGB#7lHdxc_Q+4XJPA|BhiZSuivgrK^VgFiiQnq09bGOz!Od(Ho3LzEgxT zECuK5{4vJ?F&4{AXgcAU-*+D@0oj-{Kv^?q%&YOwA$JiY7vVzOUaAgw^HaTH`9o%! zE}w>|E%NmB2tNXOQy3ko$zS_p*5F3lnkOXu3w+Y+x}wsl-23tebSK9vpNT5)gCuny zrGXMxWYu>{#m~F*BZ~*Yc*LRV1;6|oL#ho`q@h>nLlfDsg=#3K!j>(;X0!S*?V$c!H<}gd7V?){}ai#j4-;HYL>59Ij zZk>6mTOjR>j!H=WKXWt4Jc?mCB{*S@ za4-Hx%L+MfNN=aU0_+gACh0}E&(+XRtFCy>q7B=Fi2LlH3bB8Qk!%lA4&>5fSej}K z&!A9~+j>siC(h{$9bjV`OcSXslXyIHe+K4?a2$;EPSKs|VbA@t`a3co=Tp4vO?Q9M zk;GzFpkb;lTH;$@3Lj$OIIv3(L^Kg|1NfQ@`z4L7LbIrWvzLUx_6YunK zwfLXAZ~vM^&l$iBk;HU#ybvjZe-0Tr0IrDCx271IxEIa@$D<>@k({JVg7c68F5JWA z0f%j5F*n@Wn<|6npMkzoBy#SBxVkBDJVryPJ>zIIL$H=YD73!X$x0BWG36PlD3<%EI^&P5QlOoG1)0slWEE6d}* zB)6Z_PWoKk2pEc>A5j~z1GWFBsw)qK z>U-b!&Yc;983rR`Z3tO2WM49t5V9qyl#8s%R*6#Xm3=9dBBUXqqHH0R#x5brPBkf6 zE0QSNcRs)We&^qN&wG~lInO!wec$um$BTZf@fP(Y{A2#B^{3sgP8Vq|8sH4gW);jd zb9?Zooq%3>90xED?8^hhXbp4t2e_tB#%YQndXp#DYh^RBkM}=_ND7ILsB?pADcrtd z3Z3`&7rEpPmpr-;Ht&=T*;jxr9Nl`KGE9okzrkM*`kE4d-9`ix7q#ew$#-w985ELR zgHA((!~?JhuVBONZ$(o1zU`Ym6wNVY=GEjY@C>oTVZ@jf(=t0RNmcq|eQI03oa45l zEEKgqlRtLgehuXL0C`{)0Vf)^ERtonImFY=YbbdIEtg-)ZjxT|y-0R__p&Bb+3F*= zwW+S)A(tOhOFycvpX}yM_SDhCW_4~B;l34(=_NDHf4nXtPMoR0b_Xt$oZ zpwf4n>)bNkPfRB*Z&2bR^_<7C=g8RYL3$7fwb4yhQrcf-&j{pqMuwUiTuU|$GcO6e z;ZL)XN~b1z(GH09%0v!*e7$^DK^{1CM2+5L!Ct;1KKi}<$1)awrEJ;9OP824cLqqz z>*B}T4vw10sxv$p^VCkw1Ysop2#yOc&{7d+6CQ z5pys2yUFfUbno4tHbjSl_rQABN+7MOC;`2nq)EOj@GTzn+ek3Lf~IdzX@@YsO-eTb z({sEZ6=^DGUj7cGwSk-{o<5dSgFmuo?G=OgRYzw0*WY4O4`0)`im!=uVSI~AW(y-= zKM6um|I^?i4~)#OFGBG^68PC)UFgUj3q)gFNf4T$m($)eTH^{NGXV0mb_pQHg4B2eA)+d#AXr>=OpqOq%Vt1=a&AgB1Axc~l z_?ZEEjXOHrv`9U;A14wK*!g}xduF&R=)F{%vK|bF+INiv?DB$MtVr}^*;o6fs1G)( z%2PhI`cs8>H>uhk>`##j8nn|CPfv-lr9^0X@8igK5FvcAK~$)^`uaxX4`b?&4!-cJ zXMYabzFgh-<%ZM;iDBK-w%Qt&SY?TY>6*jRXBcXY(NV_2Cy5^eo*(P)I#!c5?|EqKi*GoPtkKR!>drj}%?w4KnG=*)JbtnX-5pwZK##Ud<~#qoBBqVHT?$=x&j zVR~c2MSbL6$+DmQ?O&_c+S`nf!s|8{D34$5{C&HP6+G!IxjjMba`;TSlDvWDxb;+5m`Nmblg!2#Cp$hzJ*QA0nGa9-bukz<<46P70qBt@>QF{M;{!q8{P`^On zq~#ldf`6CJ?6OSpy;9lwZc8&US(Myb%{*Q}Jrw7u&;6dWR=0=uNImtSB{pHX0^I^X zYx_rKuz9gq`8&i1hZGO20v7wc&+dv5GV9coXL?>u_e>%1rt&;+oH96J>v}`M&)3CC z_!}CXoM1%wvotnx&44OK)xVE&7u=$-)I(lfdZCFLTb-EM;UQ(%XQthX_BgyX@yba# zK#^G>h=!yXdKExhb0-34(%+C{TJ^fv157){n_U-;X_corGfiX?>QY=?!$exGu_~!qQagLHR^o z(iu~$x(|2ZkZ9lYE}V0hKxL~0%=0W{x4%=?wBls}tgzD(k4MdL zRpBqusH6_@ZQH7xaz9AlG^VOEx4+bZHm7P;e0=R8;Y}~Gj?DEBlS3bV{rEB&r@0Wx z`($I|?Ywq*RU=;GQ5AGSY*pmAu0gdOw34~~lkxh<4vuf{?N~$#F0dA@ic3w~D|>V> zl{K8|rWM~P{o04v6T7UI7x2fOZK9HKE86T_aiu>?X%-oXV zm)V+OLOfRSkgMP~ox&&ldbp+eSznYPL&xQ!r~CJz>j^s?Pqv9$)2B$<<;IG2F4TxT z{4I%vr7zz<)UKTsD{)_oYc|mmiAW(ybTkIY$GT)_fz2I4F&YZArQh&4@wTCLos{Kv z-&i#xYZaT-RoZzyP7iO{7Vx)+n1j1{>8~H;$Y`hgkb;VbLl@QTZ{5u0>36hNp!fI< z?_qy4S4j6YIq%XIn{)ky3w!+9kloO~vy!*pou@49zLICYYw(9Z``?R4Bdh~r9Z$yY zX1Z7D?hVs>=Z4pN?UR6jW&$WRan$>4uof< zrX5#oTnsbHVs0AC%xZ@!t(?Ta2}>LuYQU7^#%naoKT+JgPO>*{kV;e8Npd8&?`PPZ z&_@^=?KGraHx;(fwWeugs_3jc%p-Um&($=5TC1p|KQ9kvhCKd@H^fm9loXfz=WJ;Rjld{ zCviX-vGkF}0fFlp>tG;zrqBH_YqJoZ(z$T1^gpjfXf8v*#1t$H^zo>zr+fQl>`XMr z$HI>s@lr?~Md^#;A)+AyAwTI93G$#aU4qX8nfGPI z-ofN!{*AES%U_cQAafRAY)~*t*W~H1MH@cg^R!dD5Wm$aA$+2*IBZTuz@OT*hCeB_ z9mes6-p&E2T@k%DGCW-H7J;l%G|<8Z13C6t~jt@%R)MV_jnjt)nG}+TjSP8 z89j`gF$^C_XGBq9ZE^!iH&iq|SAarxXwd-hiE2<6KKuR=&QCOR1HJQ!wVMkTCU9#`les`B7zCQu70c5B&9{euJ zaw6==yhE-hs&J{yR7sKxx!odbLz^KOQ~DB~U#_lKUIOWO9jHtr3wKKDsa zf&j@9IYZVYL}<`mqyGMxknwLp)|TC0Gz0$mq>v~DB4hz3pqqb3h9>_cI_UFPCjeoD zi3n;lFwzX1VpPf3o!rf?K_^>B?nYg#Xhr-?n1N8J6qR<9Sbl(9%2#)am6nR;$07e| zA=aC97P*6IjnBUV)&i~sR}}2SL-Q1@u@2K0&bn_-a}wl0KwsvBHuJF1^cbRr?Wd93 z<2c%SF^W6_QevWCBe${Utu~JtfjE5PPEOreUv@(b`dynAPm2c&POz}}^}ZwGpF1alPu3t$@Wjwafpmr!OBc9oaZ;u$_MHW zre3*^WdYZMsC{^>Bb~YaF9B={WXBiAqwP;4Gu6pSBn~)|qZ~Ov^zel?MqjLr_y}ix z9f}SDi0ScD_$<!Exf!744>o6qv%avd!)?@WL99>KpV7%x*!MO{|L}4A)GLA z0Y3e=fYFGkfpoG=8(_dl=Ons_PrC=hTQ~2%631llq_%rer$2~#gP0!D_&;MGDD;N2 zpu-7`ADp{TYL*8h`{vH5aM~;xV|AZ^1N%bpd8xBu#Y3M%5(R07C~?d|JcJZy>MsbpmGAba2Yr~GZm}{(#v1*Qq0dp?BR4eSfHkpHTzZRK_0sA z))9Q0H_Qysk1#aQ*Uzm8?XWXh9YLNSfGOpbp`yJyKqf$4HMm#Swa0d9Z;|B{=w(p6 z0rBWvQmOs??^Pp?_qc8g31WVC-#(iA_3*K<^yBJm8w4f;(1kaw&+AWK7|%yZ!o{><$@qA1N)I

DU^3qVcJ0FR6}%$DRycBHFIYNV5uah^d{nQ@fzOd%|DvxmJNenn9CE zZ8-&`6VYLKZIDWRL!Ra+?9X+UH|qo*QsEgr!HllFY2L`HBN8!d6_uL|eleEX-(U1s;FRS__Z^^v0k1-`m zJvQ0l&Dsnu7LBNWL}LA)1#}>ZmOPg@dZ{PgLr#k$=;8a{AMjNh35al+SUi@fw@+@R{MhY(jTWV!W~ z#eWZh;jdA&OKS~$#PxWT3r7r;7y9_g{qFR&m{n+U<7_!SdU{~dT|Z#PB*)T^pUQ&| zI^(=WpwR)x0ERmGH8?bsAV8Tv=&-ODVR1}7&Gg?#L%No#H8_aiZ}5LRfIrHO#?61ER5!` zSey3zayo2c;#PA=tbc6L&b`D`0|S}~joxdtmq0d8UU<`-@R?(wgV?NPqjr{%6U&k_zr+z|6NQP3?z2lr0;p)UBM-_?$%>XJt_V z!j-6~3%`F-hmT3o=yX`4V*29E=!ma9Q^CbM<2fQOt2@*{me=jFuW0Fi@YB#c{jF=@ z(V^n^Gu>rnWi~}eYrieEdaCDi@5<@Pe(d(F@`!5Yo74NIZtSYx6+7GCZv6X4X7;}n z`8D6Lp5Nz}|2^GM{+7q1ROQ>!(Xp{HHTQ<`(epmpd$f##HPz*K%!6PF9i|!X z@h5u7tg{)|jJ#Iw4PmJ>By@9hVNX5)+Zdzo{V5W~_=SD!cVK033?dfxq7c8HLZW4g zML3iUego!N-7h4O?Q|64!KkD~U6!#qw{j{`$gNP)^`(7sb9CT8GMh{fRLQlQf;$q$_mt1&6A^`ff`ANU8w zfoQ9#MXn;Ww*k+=Z>$z`>NE>XCdS~N&aOv#1K8)Ll#U#97Zu>eKzG!En{;M4KxNOf z;-Spl5&#bs@4uLKYUA>5nStU~P*XT^0@CHa%;7 z17i)o*X!XAi=pR;0eBf5y0t^^udPfu0sLqIW)fKgagAY$vV?8@@@s=5+(S=7WooGk9Gy9VU>Yd>C?nC|5jp% za>zAlb|fg|2+RA>whJk6appP%YApdcu@1btDli9)G|+()_{Y`VwkHCe)Ed6(@-gjX zh1$T468uSFrKb(w8Is|XJy)vO@@@m;TMwW89uTepYy9(B)}|N-L>=JC_<*QhlaV-E z!VYQV`^`qKGb7gmyv5yLWc|4>Cw$B==;WwuAv;8V7Cm-GA13z8nA5wB;uLl&up6K-6A{yxaiVDRNIV{zO} z3@{cOQKFCnYC(&(jjj?7#EdGa6XYW%Vi_hSo*>u+*zxh;!cXC46sS^^ta>lN0}W%C zkQGXfifW3QB#@98DSEH`ZK>9O*V0dm&dukqxjP?Q z33jhjC4y%$^SE@Wuj{lfhJP-HvH%^2JUwjIF`Jxq_FXMg$<1c17LAeJW}lMdLy1D?1JUP?Ekhs0Uso?(n)zKkYJ2(kmJ z!4{2pTG0I>;5nZ!!OHjW&lf8qZbLkjrf-B3gg7DAumT zT&F+s!Lr7)sN0UN?>FAMO<>rZ)`%1hoUH8h9)DUMUE(#VM3NEC&|}f+k~$csP&%MV z7*MbTs3CUJy%V?=A^}H%N+A?g_vK^p`Q1uy#XSZJPrV9IIE4+vm`%TCsDM7=z& zn7gtWn=MV$={74b3tJGWPoa)J4atCkOoK~DG9*+yyZ*q2g z>qVs2`7-@EK|;C+%wxOs*W`|2h6{?KdQCy$Ocsa%t$=Q`0i|Xj9ypBhNt^iac+{bv z^-L!bA1+NJnuG%x!NTBsT9!jvQ1^G6L)a+SH2`Iq(#ngFY%q)9kDeL{h4VIdAo&9l?yFjEIV%tyfMuYwbRUQF6ff>^BB$k1af z05QNME?G62H0r?EDaeJ#XQ8jcnBH{=1f9jEVovayK20Xmu!um1DMQggN`**F)i}%0 zV{$+SSTgitRCuU_&G7yHHsS|HhdtHQiazFqZ-T-4G3Ty@PbbBnp}7gy!D}F7j^mJA()l&J?pAq}g#8X0Opa zu#SYWvk-CV(YCSd1u7wFk467mE)s z%av64iy9K_M#PI4S|j)Mbkn*ScXrJW8ks$=jC!f{acgE@5)go;U|RD{yNTHUpm02# z!=vO9Im9l(R9+e&Gl+C`YzF4>D|0N0UgNOYQY;Pd4Z;H`8V4uo*yJKk;e) z)>v#lEmypSy5A@4%S!Y>XKv8NN{cx*(@y*kbkx`i6R^HalW;L9RCi~%ja4?Zj|mrb z?3Xd&m7U(d!^7RdZ}%+Xo(!oZf6@%QylWA*s}0g+^FL!dJ%Y(Kbn9{B@c zFxqIAlB>vR>mTl$i9iSN7046r6+^g7QHYaMOH(=pU}5`krE!0fwQjk^g%zfa(>^{a z;iu#IFaMkKlItw#5KsTG$4^Nasg@Y)eheR&Vz zTTJ$r`>u`eBK0{Ik~%_df6v%P|NDFIMj=KOWaI;!p|gRYf3m|6a|$g4!%%fopk=a# zz>R!GH1w#{t^V{cpdiY}4MTG*WOQ&(CY*qi;30+~+rSG9E?nv5WE+rsv6W;<)`(H6 zogTG*V9p=ju=FGpIobE6vY`+Xd>cfN;FI`ve1>=d> z$+Bk$(`J)Zg-^ZXmH%wRtNL?U>($=1TZp0OrOz}cGzYDI=BX>D-BU9({ z#Wt!u>-vL&p{k{sxZzL(+Fi=OE3luTgOmq<_1Sn5Wp3*2{E)f1SVw@5Y{YgMx#Wxe z6?YcbXKbDvPApJKUwo3-j)NQ;TV!A#uZGvZ2;4LZ+gOff!HImp-a4-(ST9~^F4xJ9TJIR3DGqx30ui@zC-OqRo9#T#GA^s1ZDRXUrcR#VIC=kABMa>hp>6& zvyxfbbocyHHEH0y73Z~eRrx%nerePUTGGcUaAo-NmE$i}oZIz`)J!K&`qV9K@Q>@a zFtH+~*sY0Jo!@_@6 zwhS(%MB`iIttj#O zI9PGNpkHVNM&%GHF0m5n7_D*X%-xIoi9fi67Br>aGG7SQ+u`rv;w`grw)5bJO^u6i zvauUl)n?ch??U*5d)s_0B1nIH#LqV6vDVzOO70y`!FpS*-ApqjYjD0T|6xt)UY?zDs={KRgI5sRK>)T zJf)OBumd>>`^^|e2@$~2iBr_}^A}o;<10MfUr8-gwA{A2_sp*%QNPsGDgSi%eTCPm zDi4@X-ZccwN?*5hDul$IAMUv>8_Ab=aM5X9YqRPgMxDpw->n6a3n4V%zw&i!l<@HX q73&Kj3=HQ9ZF+0&QtW@f?_gQ#k5V#r3?`BPtFdpdo%v%^IOcz-&j$Md literal 0 HcmV?d00001 diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/bg_enterprise_search.png b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/assets/bg_enterprise_search.png new file mode 100644 index 0000000000000000000000000000000000000000..1b5e1e489fd96c8a764a2e2802f42a1d77272ee7 GIT binary patch literal 24757 zcmXU~WmFtZ(_K6e+zA@o-JReBm*5sWxVu}>;O_43?h8UzO$6B|nPutB1hF zT>umm01En}I61fV0ia=F$EVkZCl=>G*9+^Q`L&~k^`qIqm?0;JN?3wYm+STRB`<1I?`+{@3)sXYRw=+R^m&W#Yop`qcRH z!pg|`>Hgg<^l2_>w7wwrM4(w%KkY5M+L2DHZ{n%`{c*5>wN14@6(#iT*i^7_LFeIw`) z5?~}dHe@n6VK+71xVV1rqjNhhazD!P=&JJIsc{+W5;QaSJVF6*p9BL#0B#c|rA4p9 zc+VrmUxH_4GnUggSEzn309+@^*497zd_2)@+gW`(A3Z;?PyN;h5K3OJ9^Z>v0GgI= zl&x=S=B+w(bh5?H=kIKIOalS#6ZTab-eUvsUWs5GpZYNX?!bEo+1Iys zkPbV*eG%ZX2JpPNvy(nI5d?T%zrMZ#JWl`~+W_Y=jDmE4$3DP*@N;E8Vr?sIoBeI@I_!_VsOd^=(l-?qKWv zb^+?U)Z6^X`@_!LZrIy~)BE+v`$^?X!S9z0|F<>s_vh2+$X_rNUjcZy@1UIb^S0*% z7bqhgL|(4-`ln~z}2Hqan;i!8xF4oA(xYbUBB==hux z9eVS&`m!3E?V(K{tE!gKm z00jnCl|j2O3M^v;#M%nGLGi2Gk^}z4^>)F5rP^Zvf^yybze?u=tJshcF1615Fn0W> zZa($lG|eB^I|)Pp1jT*p$HRUJh$5LwQq8v;7O$aR+GO5v_6fO#e$U4gMT&wWY9Cs7URCIms#(nf;DK*p)cYnhG=gLh72t>FieUSF zZebi1@CK$|NX;*wKly;WoL0Rvy7OW1AVAkp{wDz%+eXSMD39W=lQT~jjZRg?e1P|T z?4;&*y`!)Hh_hfWam80gg`sUd!*aaV(o) zE}?3~(k#x`a=!jm6L0|dludB9v>Xa6C!I9HTvC>jo#LEa*pc235CxPHmQ-+S`_m}g zr8TWWJ#dB_S6ol>RN2>wxEI6m0*-brx#(^tZC)4c&UlX-U|2bp$*ZqBM|TW=k}ft7 z1!#A|?yi(CzgI+bvd13+7>8VC2Be8IYu_;sHTX{fMuE&?I=8l6XOXXIhl`NKe}sUE z`|h8lozI@S2&b)8g!7`o!~wT_II>!|uDI2YzhCiNJ-Kj}i2+qx#WDlev&~nwOsiWG z_jbso0~BlkoB3jyIj7STZz85z8h^p<&K_heK2*T=N~e1|f0PHlbK_e|vX!avGl?>V#*%P9ZxpgKVC3SN7+uGOcdWSgClo7 zoc_EGTYEjol_1O5*^$>1Q5g>a5D?!%lr1E*Nd$8H97JTAOB*%O)gYR#W-egTEe!F? zoYIK(;YGia+Gm?Bl`tsxp;4TG0sve}qAxf9W^B=nbjOSG;;Yl2w_1A>)lNd3j=P;e zp80sz9v>^+ZO2o`1n6|DZgjC>rZ4n``Hz#D;=y6hJ`=&m!0^JO%u*C$cu{C0y00}x zeS~VlQ(-)YP|@b)fobE_!}+1=^ju9I6MFCV0Uf*@-o94>qY|~VTCdX%l;>ENvaWZB z-1Ci%$B-kJpa42;`n1jy3i05};7i)A?uA#_d2{4MU=&8dkwKBk-dTw2`X*rqlTP8R1ejw~_>%fVtZb z`P#W(NS^Iyjn^wVgk*ix6YVV#PkTk`gZupp9H%?|dPGE|C@r_GZAshy_mR*8TR+?X zW-b4E{tLS{jXTlK9r-ji>|!8r@|jl$0V}TbTB~hJ&0w76V$YMuZnQPTEQs5Vp|h)B$&p=Ln3{LGL!2t+;u9fQs_5 zs}p6hT)(%x*t;o7V0Sn=PouXBF3KB!9zEl#%Xa>e@g~}Vu9p5TZ;9(B<+Zwq(meix zt5N$8X(k&QAu6k%9)h7&!Lwc-jH@S;UJ|Fn*?ec!%0j+>OA#$A-;%Zs*S5yZ8w(q3 z+{{7abFaPc$x|gFiRv7~x||WxW^Jx>!u)V7EvGS841@eoXGP^(If__7MIk1`41(aESvfIxE;z;(7ybEu%LWp92rQ2|oJ&sOL3eHJ z7$45oYOFNue|9ibY(}i8b*C96rOhVG3teOD72#NZZ(j+RKiE8r<*1_U-U{s`xadcd zH(Qe9sxkw*+{8=YaC;9ohkomC14`#vML;jEk9ND$c2NIX^0DH4Y*`6m8K1q8%?>Tn zCHd~kwkz}?ZE{jipU&v z1iL%8J&@6ft9%UAm@7WbNfS8>15Fm(vyMhd2hAF``315IVcg3x`J0O6mp5>B0k;8F zBe8`}DnvJd4fUSkjlZYtge7e(=}S_7_^Q?tmBi#L(-HMgw4d+OlOwMoZ;oB9t#Wy? z8qPg8m(D8UrOw|KXtIlW%>KEl*F5hAa<R-{(|Itbg_nPFHLh_Ag0UeCHidtf=;=tbsN!WhA@WxXj6jF z>0r8-!h*5~n_^}bgOpNKM|)y*f=fXoUcwb>EUG)&bjt-@L3@1y65)e7zc^7B2)-#x zq;LPun~U#P84mO>?;68thEhoE4@zG#_P?{kXq}z4Xn&IUd<_#uxZYmj*{L*+9C>1K zczG>;PciU*Dr-A<+XwMf%&`^2$rEK9_#C;ZxR28in}y-}DzlIEG?=jnY$SL8_*=v2 zr#!#SQm?0KMSyV3=x{kr*q5ZNJmlW-Vv@LYtap#wU}6eJhM)Uj*r+=~(y9;xY1u~N(9v(!w7+l&jK)s4OrOfL zW&b35_F*V7pS#f2TLs-JuS_`QVDi{}xP2t6p7QzkFO?QjP@=h&+ ziPJWcZYY0eg-0U7?3zN&KjT=()Xfra^44fnr?NwTdv6uhAAG>oCrV>>F_u1$n zk$-Pm?Zh<-T$gAGHvcg~x&=hU`dTvYF4pQpU9)ABRPsrTgLnUahun~yIpJgw@O0?$ z$J_DODt%a7M3KL_4x1s_CWn!3bgch4_ST-HJP)89G24&2OzN`cUg1xewh45oEUrLm zPzt@VUOFltM4nsDL`XMIYL+OD5`@?buLIjw^=qz?^rc88sSVwx@a10F{eCv0_H6%a z97ijQgF_%kuD!@C~N-Z1AZ%b+2)~+)x@4JDnbqb*TG=a-#D(|srXD_)}wBB$w z-8M<|IhW#dN9pNHPY{Vu8BJO8QJ&MB8ILgeHw%!u3DoPK%~CnWLXISrpVznQAlI2t ztjq?**6f!jEc$_d8$FgJ@T_Ri2KC85kCc|js1%lo3TP!6*&teH_R;Z&&>>fkAEmnH zEa$t|HXI0u zZS0Xp3>SSkobD10<0JM(9&IqGbZC}6?6XhBM_+HLCgudCc6gI%#`O3p%pRIPp=s-t zvYeQ9^;K*NI}=v$t92R5^hR+t62}acoNWs<^DpeA#hG8pv`K3Ut~N<+O_kMMadk#N z5gEquLgWh_l+tuc zpp3NIa8f$1<&zz1Fq{T#J^Og^R6uceAG)r;_J4(Of=oF!M^GUg*{A zdAxQhi^!3ic`X%q0Uan>O_b~P15|K$7FGX8lE4twK*l)GA}`;rDsj*j)qT0Rs;X#M zJ_Ui@vp$L-)_EP=+^TQ;X~x460@C6@HHYe5RW<#$Soy<dRvyulAa_#Cdq*sIRbvWCD*dP09Pz~IVB())} zC$`;+0EIw=4L(^KNJj1O9^m6_?3QiYX>d*p8*#g4_>xmdHMnWw_$etOQAa5K;~PR9 zB?}EkcUBeily3A3DYmmz${bQkJ6RwQ!7#XI7<+8PBT!}chN$N^CXbBtcdW;a?}al) z3NOEj>T;(Vy@r*!{hCKe8HE|S%I;TQLrDXG6uW5MgpMN4uo&=2|E{V(#Y1;fp7eC( z<{#u2o?&+gbT=GmTEokQb|PF3cw({?!i;-x6>NO{ch(&{oK;kUlco@ksT<#>*AL93 z{8p$xiD8eW{$d9_xRj%z2;4IxKx=b`cg`FKEuDaCUeJuDeEyu?Ji47cZRC|9pcy-g z*|?HnGe)|hNe8_l(L?a0!7UWon{J;Wt|tiV=Z(kZ)^;4{^!#gErqSIU#-&^KSA5?; zra-fyRL7GNg@o)j1o3=WRiUtmje^H`qKX-{aHIB&>3&g#69e$G&6}wR0sSp{|spU#$#_e?U&1^SVCnx-gT)EMhEQ;%Gps zRn@~FmOothGlP+F9x7$TsJr5gtm?Q2b_xnki897}`B55Hx-w<2-bKqC_8AFXR<`V8Ul1_qer zJmZ~=n{L>I?S0v&o*TX~Kl*z@SR?OGY`tvtk!Muq`vty%MSdq{*9a>-877o3+#uUb13K9Nq^(1s~*Z zJQWTRYI8vubLwbD=?oRrc0FmFsq}80SGr-q<0t5+$&4|3(pZH?%0ZEea>;%{M};3(qe4K$GVWJ>J&{je%K~U)+VCf>Jt} z6YGAk(OmP%!V`md;aayHFNn@HNR%7t=|Cu^`nv^Mj4tUD5fw=s6+RrFH|`$QxEy+P z=t|^W*TJ?`>ACSR-1Ud*V9~gFxz0)8KGz!(Bh~q1wTvH|A{~hCMG- zJ_#ua?*708kkIUqQg0m@__R^+INGc|eF;@27G32V7*v+LgwPY)ZP&{1@+xFMOM6ATfjL&jHJr zoF0!w+ulF5|KH`IrAOTrF{Ja!uC0%YIkQlM+>2G7 zJvM6^bcdj$cJHyalVet*VWO|g=%uZKwn|`?Z2&XYL9MazB>3Vl@7U?;^8b66SrMISk?S z3=Cy9g*DhUoz9Rvf9M)Pm1i2J`7v79N$~PS>=YFL^2Kq@87+8bu#SI-i8 zl4eFA1`uuYP8F_W_pSQU()>)P18Wn-u4vE!`!lmmfUk``s<)KR*NC1$OAmO(cRY>- z(!t7Wm6d{$cx2duhh?f;X`Tc_P^)tqQA=xulXmF7Y4_!Hd8B*@^S~&(M3adNTjirF zVRTM%GYwxag?^vAx#&J$C|0uoxf|Os%AmL1)5~TC$#22~Zuit#(kh!VUzSXZVDWr= zAeQ0yu5~_%Udi_9KY1d$f!3UQSJbESx0l9#FMNl0FX9yb?8wSE$JCg7WyW@=pWUSc zsG1oFP05#Cz;Go$p~s0`EiyaUAvj3aavJl4sXD`&_p|6fdv=^g%EZ4kgS2{zp^RxR z+zO}B7iPAa=^=}mhlIc@BfKyGPJZwq?wg zuFc1OJk~$)oJ8+&myBZ6^2H0}c`b*Ao7=qR)`UYITPO0=Wj4NsOzJKR!=z#@`AaG3 zRsN2$@>HZtM$%mlZ{7H|wzCxXurxPb`()&eJ=zR|# z%V__yk)4WMY2L#K9>y2&lcE!APe5+}Gkr*HR0F4$Kf~Hsrx?BNgV7|APLdRVeIt(ju$2O_(NZzz#X_CXQC@gMb(ghaN~ zRO?0bUfY$LoHq%+=XT%xwk`R+$2$QEi5D^vpG8#H1d;OkJ}=Wm^{ z5d}kTr*$U{R3q7166HQ$VdrSSCIN>-$x+E1cPG2jSBWjO?Yjg%Q?iN#HlB=(9zKKK zkGkKnUSfzGvzlzh#2ho$)~3vD(Om{HAh~8NJv{Ir63|9-%Z*RpUs zSgcv1J^?e^unokhJFW1BD%&!0D%CHUzyb^v9AsmgQ0B8M3f5{}CyA!T-@S8wp98vk z-%%zS;#%+A&iL)8iykHmYBLk)rUY8x&SP+C8@n!ernSN71TH3!B|B~3u{WKuUB7wjgMWozMTvVsia9eN!=TwN z{S^^{ib~u>w4>QPD9wHf?@FuBNzb%b|$yLYC@4FH$I-BKd*w zWu(u*^wvvC;<(m-@rDrh`5B>5c|ElGcF}Wq>K~r<=lH2McXvtiS$mZY*eA6bIy=?l z))9A?y#@C=$Z}vHwoCgq=HbGZc`zz+$30lb5oyvBo&> zc#EjKD?7Iri=W*@%sp;ViicCQ+=4xy>+;Z*Ve7b-=tj@@7DV5b%=SxjUHY$YbbT7j zjC2tw6^%lSG=&dePwQQ4HR#K9tL*prB8Z#i=t8{t56;UZ=x=4}Wl^o5UMP-b++{)|Tqp8$7sg;7 zqg%s!6VlHqNLu=%Y~973I^U}xGfKXiSW!{obp~*+TYQykk zIn98-Gaxwf4LW7yI%47#~hMbX)`u-I>&YeI+gojU%m0|l`Xc}}+IY&r=w zA9UEZG5wHSMlu+$ZK}_{ZC`z=rdr`>i?i3}rI{AhEJLk5=3RT0iw*KdE<;~r5(u`c zr}hiExiT--u{z2kaVuHYXmFxJ2r?j{nwUOjNG4(^OI;n6de&3_OHn`N!5c9e<&(&UhbO6F8sW9_6RT!B?;t-(}f{@9V;k*Dcv!F$GXcG`g@LJL?N8&YX|8%l2^_ zmEFvY%C>}Rni2lXoH9;4bxSl}Sf)R+_gM<1P12ki(+h)8`J*&5c^rka#LXzBVb>K3 z|JE4a>cIFWP)#J!XnPCtKA|K{)GK%x}a_wXgBvVq9%BAW)-Z zy&V&+Hxa|%Gib#-AyaS2sDAlvN4=NEUcVJ(bOhzb6FTO)qes8w@VW?cpwdSaUe!q>`^_xpu5F%#1Pr;rdr)#+RoF!=VOL-;*e_+zTpzVBYmRO_ z3AzYjuI>-qCZ!`kQI+{ufo1k_P09#{r$u*{GikKoa|Up;s#S`RVXrpkZ>P>3Qj6L% zAgMb|c?AV$cPq{hnQ)1f+S6}>Sl)T5#_q#d&b20*1~OU|6;$YV?%Tt&dp+@NxhzV#?8eOKveqOcj_%D*WBmS_^9Z+DLytMRoTp|ufy0Cehl(>%ler*SV^<&8s zEA;u+Xmh;WmS9L4+fK%HYwo#lRz6BLmaSSn2XjeJ%wTUvzw6sWsQtnhwVmgDvlWS% zQOw7OY&6@B^QsqWP59IrDc->)dUSTxx?|IYLZ{=Wv=)S_C?w=k0_#w#bW5ra6TD@9 z^(a6Jq+ZxQZRh&!TU|I>?)CapmhA6YkbgK#%fnGhBB>2xSR-=BzlFdby9ypfh-?lh z?qSC@jg@c@b&}5qT;!KJfsvg#K&I;O-o5FvzBNv~LhY9KBsQtcC1)FUre%=AD%C0F zW4Aa7hzE>uY4yY+1oA((dx&+hl^LO3!P8rtd5l(H5 z2o)9}5wH2;>I>iZ6gtc41TLWMlzP2V>+hrepq}z|>0=#mUK^eX33{)Poa*Z6$j)r6 z`OIOL56?t|73R#ArTIGIv&?jRwCi7v_Z~5X4v`<;VQnW~={FHEQH$+}y$ zAziN7l-1TUZenYn?`Sg-t8LFzw9~Tk1M_raH8u0^g^aZ-2wi|C9zaV z_DgHxc_}93tRC|dj1L#CS)2>uV%_?@Yg?TQcWjAO1?t%eKV+1K^D`Z9bSBJ0gux=` z?u0sI6D*{DuCzy@<7_e$pDLD5&|WeM1Esu*vhLjGD)tw_I~_Octk~Ek zs6tH+AlkJjl6(^%qDzNRQY_fmUU$%8}m2($xNs@Mhp<{^Lh zA^Ii5lULdMxq?s7ThN|t`21BBjosd2tfw>4H&brF36KM(R8~+A<9@%DC+^-HzT39> z^_>6H6E^HbQeA=4OhJb?6DgjMBXQlRaY3j4<$c4+5B>BvfYUl_Qwpm?{3e;2O~-Ug z{{Em#oOkjkeo3V~9*Pqbt%t;+_IX+Y^5d}hzG}Cubzp6*=Rnw8X8pCH&b+gH3L#`X zuh2S1i_Gna4LsAKz%ru>x%a>pik3DM%=|uUZ?#;~+5(IyVX2SwMyGbzVh}8vS1j_r z#*GM1+nf249%#WZ z#7Jhg)KgS18O2SR#%9+TskpkB+70|w$6P=is0pgr7a%6|%I+cV7xuL+3Yx}mG}&u7 zbXuI<3%j57L^m>G`lNJ;4^cD43uZ@%swC2I=Yz$|e-FJ3@{2V*n8LH3C`J1#Tt^3K zHAESLrJUH|gES@Ny9s$_hC~Q6O!H92YOIUY6&e_m^3$~~mzFhbe?{cp-w|@h{IqtR ztK```AzESE!r44~psDI-5mXas$b+@hhq8o^X3uf;2YTFdcx#i>I5blZ)0~z^ZWf8Z zFnVs|tevyBziBpJs!lImi`C$O72Jw85zjt4d-7IIlx2tR^6isYul(1C39rnW{^38# ztf<5umCYMDUwzNyQdEG8T;)>Q%+7Nu1>&zrgM41hAvQ8`wL7 z(Il9`(dMBm*F+j0EbRW)2M7)r183qWjv$ZzZeC~bBvD((c`tK+OE=HIylhnLv63hQ z-D$DM0u}b7QYnR*kxhWTNr;B;m=@@os6nx5^a>G?-ZZ-oAN%*)3rKg%+e^b?S#dNI zafAp71Sh@a>g89XU5Aacg;HBdwt6%|!mq&;m(O*14;MV2*I2^qZ}j|S=M%q3vX*iV znmy3i+*Ua@$&5;H)5*wguMSK@@At60BLe3?{nHFOp$L(KK&v%Ht}ydEm7*Q04KV+i z_yIhMOIORM`G8F9+O8B8+4(6P%Z9JTXyTbFER^9mBR?~x`t-}VeK@Kch<1a$-qZx4 z_Jk4|mAmR6{a6na3-x3ODrp_#=boX}RXDJEj(?yG5R?pD&ari}?eEIj(I`KKTCLoJL)gB%j67h9qs-P(BvLq${t0bTF z-a(|#B^E@)p*dU5#}Ph!eBttzQ7}nv__$nEUwvbk8MhzTbh)rqXX3e;=bMFhzdV^} zu%mhw;?2QIIAKv0YOQq~MpWmThO*o5)Y(f+myT&knDWuhb9|WIk~v)WEh7Sd!IVsK zY@#S<5UJJ;sNxdm{^qd%?d{D{=!s0a@)^&`s0Pt~c1FEzrw+U?4x=?2ktg2q+`b{L zRoA)^?k;?7>G*!Vd2#m3+pk5D_{oOJAmr1%58{F^z5o4qLSOjia8t(4w(;`deq{~z-K3mRmp<<-yVf0h6iksJ9H!KW4-$1LP`o;pSn)-zau(i8)$~T? zM7T;k$NVZ3lb)@tXB8x#&Z9UwSboBAGJUy;aRj-C=I9A1L_|jf1jQ~j^gXGgyEKd( z3P&Vn+e@X}3Hykmbnpz5cjlA_YUk47+zfjmx^{0fAg(zBXFntt_@LC5ns_SdGLCVYO{Y4j1$#vt3$fKkyI8TF@de^gjqe^ z=BXR;V6PJE?IK0?s+1%pNqN*(4jOQbe%o5jw?1E{o4xu`m(U4a6c(m>CkJVyG*Iji zAQ?STH{Ua;!-BX?Dz0nJqwfeu$$KGs?McLT@(SbG=-+KR(OoPXIK5``2%B00@1&Gn zF$sh9TLY=SP;LbvOmIf5ojCZR>i%$~>H*9UEKFrqMA%*Q{@DAOsx>Nq=h#;W>T@SOnbqPiH)9%Oz2cZX*k}rpS`3jCShQBm&OIn|+=m1-QU( z2c;(xv!!QHEg7s}zfuB}_7thXZ6p_o?4NN- znjl$?LGj~L1$tHIN=-8_lE;mV`w?74BY+T*QYbt4cl+CJKMK|Tav`6x;R{XFIVjvV zs#EETI$1V!0n$qC=2G&HZg|)VQ-o#{tF`=4i1?{9^Y{^)X?ia+wvdw}*6gG(K@^a@WqCgj3JJ@cwQ{eHP+KUr6 zz&ynpAVm2m3>z{k+Jj(7n1Vn|Fe>u+sE#=Gl0_8VUK5A=y5T03dCrbTb5agDY7ka@ z@s#Qp9eW?a__H7BfxW4`J%RZMrQkg*Hj@OSS z(8#*(gW3Cl?DEg`*d~3ZfC@rJt`TU*Q3OyE_n&>EUGV0qFBmf-xAdnNe)<8CDB8s0 zSB7(om+`Ik5h;~WV~^4=SQ5%*WCm@B1?0a!{WglxA?(VBtA>7KjZ`V5mMR=7^N&ey z6fiH&%+6+tm_~EuT_BENWSq7U;2P1D@y8F25c^#g0Z)m;a+SloJ?S>%mYu!n?4{G% z35u{`Qhh(pyb!v$_Y=UBEY&YLdd8yZ6?VDYF$xs#Jj9G8y61B{9i|kX9OJa@LtE@6j9nw!$dGn|AiaN0wS6gjpGtE$LTU2?IEmCw>sa zr7iiN;?WjXM*@#f@irs*c`~nsodj7KsrhtSqmQ13Elo<-=t<5EDl}>+yFE7l#xCso z>#{Q&QmbTnfCCXnXUj!YLqpJ%BMUsx?H;Ij6#j=KI@wX3dH2u5r&!WQx|m;w;_0!6 z(K*kqWkk&)qP_@DzUUGbq9}JejdVCr8U97X--nq}JnB+Zfv&{R4v~Kn9oY%&9j8W2 z2QK1Z3%c}K*(|eF?H_qNKzH7v>t9(B`rX}K!V%n3*X{Qq=l2pxDc!Z~Cy#e$&vz25 z9??2LF&NE;rxOG}Ui{BHgzX`u`({L>fAiNKv`Y3>rDlA&$U}a#HF#ZSlsu96)Tl2x zA7kM9w|-||C3noPmap9vN9<>n;mO*4wVGcrM%Wy|9om;5uOa6cx@pgQYunlDIQe4& zblXno-L`e}-?{6o?Uvjdj-+L(OPc<6;TGU36*Eim;MHY-;Iv{fO?o?D! z#8F*C^@H8>+*+CMi^?Xtb}2dpb5J;aZCfO63iWjP#a`?r(1qs`v-TaoV<#)pj-JHx z0=_~uJKBy`n0iEN>s1WF#9_BHWS3-eAxa`MI*&PB@ZF|dW#_Fo*Hy*K%g|)eIlxNB zKkj3A@sw^EB4QeNlVsj6`~@)LU&tL!qP)Y#o&95fidx+4bSJn4$@K)I-tTpDrJ7;V zEZH@%wcPY%=@JxkU!!t!nke|v_O0Y6Um~anDp`DfEaz+{5W%pqrqZg} z!9amw@~~$}yfZpwtW}skY)a&hEql{citu{Vq4R)8?SP6=02zT~ps3sCn^WdHdZa3&_+;SwD zE?L`y(r+67M9p}sd1HWP6U9FF>~`kMzSXnVqk?kGgdF?K;lwH+etO69Gvrr$+xA{R za(NlOi{`R$+R$G6wkRF=l%xxZqdf&u>Z-uRwI#N-x#}A;6qBMTT|ZUFm}RFmh*G#3+;}(x9htZ0q zjI#y1&rq7;@0sfu_Qb9Y+*ojq0RGrYNpdT4J}6K=lN93-i)jQhJG?R8)m{6G z@YK57Ok@b$DI#;+2BZ0Kt=~1GTCI1Ksf9v>6fuku#yh%q{=p^YH~FFBxG1yU64y+= z(f|Uqm?4#;nXJXQdAk+1iDRq~QFtR$+--C*zVb>Ai%i!~0mTtccKl3&d=NP*jpZku z;WXilG=cb|e+fN2a`?#?Z;Xcls1S6q+R)mb--`p9PVvN}`v5iebG7ua;zY2}Jp;xt z3BMuQR&QO&W2Utg4}p8~P%4#f7>r%V9{z9w!NZ}6PNd1PC&F9cKH=qxL-WQv-Kff1 zXigMwR-PET$rG)~xkTz_CKHZ*@deegs`N>xeK8*7wm~Tbjwk3QH8TeoCl@vxf^w? zqpG)>*wBZCGKn9oGq6hjCs*p9^7o#se+POnBD+pDHQy_b?7Ok3qSxFFrRl)^LXHkH z&6v_A&1_l?@b?y7-yd499St(OT z(|8NDJTES=Kc67=18)T%&LbmGls&UF{Uz(UQ9XxW*P`Yan7=;z6=30&agHCU((sWoVY%rPviH$*R8PAfi2@90%GrjP z@PD85RjM<2JE}1t>$3pubc%?*hWNSm!9Sfx3V_3ZHeb7HwIc6&kj~hpXO{EKUPy{w z3PIQ?*2C2ow?&qbx@&hjSrSKrQn=BD4Y3s?6cVNoNto1xaUX^A(5n zL+KOFx0LMCol9!lYEtfQ99Sz2>0(fl{eTT|Q@Ssqu5O zui)-`!c7Y^kfU<~ET(?_Lu$_~*e?T}ia#Yb1^Ic6|HL2fuSWxi4CemZB$5t2}~+lm2JBFjgP2sr%3N$nfLkkTZDZ7 z+`JXsE5AC9(?k{p%)iT{4ESkp%y!0Q=1>vIu6|@dHs;4Ux$pEpF96?r(I^Q zGbh4|WpVI)cn$yNXvT7cqEhKkWlrPsd6N>@JqF!{FRLFPE_hdZ7LnyfAkq)6Ig0+d;`{xPc&YPxFqg8!OCl^Tc-%Wm6SNv^+ZBr6qzki?@c_^4oNWgNn1&6Vga1 z7x#+q{U}{7a3Z!e4C!ecKk!_1!te^+Pr|s1MJ+B(f(}fG4yWgQseM7;@twcg%E1=J zHf|(%g1^Njut>bV()de9wZ*lk=3(cWK**v&dU>PdN6r8_(@SbiklJocl)%L<=3>1n z)DDwvFxz3<5;T@C9eY1iz0NCvVA+m_oZ?(yBE9M+l)m$*3t7y0(nu7Q^?FN_yPIto zZo7hh_L=V55>la1l9e+#v{<2Yf0d-dO-@g8ZO#qp+^7Mt_qU2;6J+PhA_f0*e{&}- z+$+2UlqQ;RX&?wy2}}lN&99Azb&dRQ+L%XE>2uhfHfp)W{-S@_Ka}yg90m=kJ=ZTr zk7IijP?Gk%?HEw~b}OFVrwi1j{dS%u^k65`S{dp^V40%Lg!m8JQBMIV@u4s8{Dn)K zsHIPmD9HZXzWOJVk9YhJMS-O-?>%R)mHPSh?W!a!7iH~J9BW4LM#;!+%Ef&h%ZToc>^P(fmO`VVBtpdfIbVKK}=-eLhv(&OI}5uy~I0JGE=u%kH}66-yh}-;Z}Jmg+f!L zntbp4HjRWl5{%JBScUPfc2F)27qil0XpZL1=EY|xWoC!u*B42PEUe3r^Uq$;e(j_J zyM^T2BVb;w+pRDDV*Zp4_Aw<7%`8S4)BQ}&H6$-@*bCiHs^=(R`VKkFg|GAfod>P> zoR^}*d{qobMbJn6W&Al+2bsMhxQT6JSZwP)JE;ee z)PN_H`O4#TkDfPa`C0!lXV|nJk?P#^7InuxcPHe%@hTB%VsUaMma=Fq#NF2Eo=D7y zheJSvu-$#-7Sq{W-XoqAutF;2V4+$EJjkDs@4)4G#HZ@AESu!Bt2d%-gj!rlA)(X$ z`}=a*W4iW#)1YCF3IC( zxs=NF+rH67FEUxic+XvE9UTEJX6`S25eqq=YhhYAIZp2l$(i7hJcr})VIitrAM-Uv zNUmv#7RV$5jPUuC|zVEt>-6}5&p0J_<6+ti0>=5Xw72@zl>u4W6ZWY8%8DaWn zMGPVTXUhz7woP-@Ta~y@+~1e+^IX_IB?TxHtAS69`QWp9wZMR~;a*V7&1yS)R7#j-;MBW!xt) zrZ1ihU+DFX^)gQATN3BZpeC5KjI{7KbRtd&v$azytu9Zn#nuHc8Kj`AU0K9J@rDLf zb2c?irm7F*3|l_ZMm2QHN(1=60(Yf)8Pa^!*tQOFSikv>QuS!Rq0xE9yGreGCGfe&5x=nOA)=Kn{vlun^a(S18YUy7(qxT! z*~*lBwH7bw0Q1MtH0V%Hg!j8RcwO%A74#Lz6&s{xj{!HrfanYV8p9Dj2xxlqhQDA9 zf0x_WXI#M@{l5SeAnM=X1Eq6JBt8tq>2m~8tky>)6LFbs>yk+)M+}CF+=ulsX>hOh zbidKi5c(_;S05uac&sM$>!g{l`nOrG5Cujl34JI14R$z0Wc%PNg$|KB{0ug^#|V5r zM-VNVwvvwMGdvT`9{8S_;mLkO`u9i;G=i`pf(vU3FX<}{9ig|r1abRuQo*r;Y_x!-TMsdHK+%a<4SCfPlLNe=_?F9p^qmd|NHBCPMW{J*KBe>qL#SpFN#~F zBy?zU*PcYz&)`{>L4#cReEL!oi3dFg83=-e!@yLJ{=G)@Ne)j84N2}@MrIPjyEm2M zg9kw^uFtHv`W*fRYg=jxeT*ygyjPe1YHQEs3i&_1i2gd@IrI-SymRO?m4rTpO5mpt z|20m+)>q1HDtg^5i&0>PiNu0L%0Lh#bl^-_x2^*Q4em9fU!P?6bUKm93?12}OQ&k$ z?$V{_&`cto?%qAwr{9PXy#@~&&^6UL#Q1DedEUzoY|-V-Z|SbyrO7(5} z68`@}g*Nx78r%i*HW-Gk(09Otc|ip2zCwSj_k{jcyCsg^Bq}TnLFj-bZkdP98BQs4 z-_#vAp6YeJ&o+@5fYWLS(lD%)xHJwvU%T1R7y4)d_-}C+d<-n~C&7B+UNY*-HKM{~ zy~te-6SvGmHo9XqncQ6>#~imwj!rR==)qg52+}yn+uTn@?DKi+TN!^K^gZsAISI)1 zz}w;;2HWKRfrj{mKDL8eQyiy2O|6Gqte<2&+XR@L**2rp%Gy&+Byy4i4Fo|N1xcHG zZ@N{&&tCZcM_Cm8j1TC%|mOzC8EF>HK8xI7!8IKY6gFX+FAIky)|(* zyrl0mk%;L%#6S?FacDWOQm0Y-e8q;ghCoZ`Ft?T~R5`h(-xv3q2;Noih|5xct`HR# zsR@0lMCkt_gFo-4>;QLc7)ZD6NhYEN-c~~pB%H3eD1AO}#rlns;#^(mi(HWtmm2%r zf$M>`%iMoa5`-(9WznBP2j+J8)77v4CW9}^9IOsPx9I{CiI(oT91cMmjEYC}h#KNH ztysBX<1PaTL+Cq1S9_Vz3kA8`AKy^eDg%7een6)eFB26ODhM4|P*gM({F4mcq1075 z^Mvl;;@3!lxE-g}5TwzVRhGH`bDz&!+frE=-}%${z=z=fo4bw`e90pXElcS))HEg3tOsr&oyzV^oE{)W_AF287#Z6*Ktr zRyOO6q1)7BiHSr;r#{%-jv$T5u0sOVi!A#2`eyM~9=6bdQ8~9l7eJlv%i}u=1^Rcr zydAh$ASz67eXbz%7Sm`oEcW*@_|w1k!#SMVZkayXM3lhY?FfR@BsCsqr! zS7?aSRrz`qmFo+A=5M`lxtg7E&jY`pUw$QDD07n5<{D9AtCG-xiKRxiY}=s~Fo#ya zoj=at;oY23m*O?_HYO4g>AnVnAa%yTz5&`^dL+aY%ixawujt|XT;%I3*KgdVkgsbC z-81^={E_Bc=(+rH_*k3Q_x)PjYYw45vT1EDULY#uCX2+~!`J7)jA`XJ${9$R3K-ZH zU`!kOW&d5*m1||_4&}>^SA-B(0#4&Iq=6tIjFpq zp+M-5=yP*K7#$9wLzmof5h}nf?wSVeW{5A1y8FXNJEn}1tY4SWKGB#U{kVA_f4e_=zn!T z%s&$PULsDYmL%^%IR5729a-Qme?fos=u?&o!Y$A*chC_!%%X!=YZ@J5bcwltMf1E$ zlN{LF@t)| zw#v2h_3(w>?m}hAek`BQ1@3yk{_xrRCm){{d0)2by$iSeAL89PdY)fKcB1%9_64*TO`CfxF=lq_OF5 zmeRF~^?RC921t#5Q21QE=`DnOJ1E34 zE?)t=R`^^+eWMdzRF-AijzK@_UdQ&o7W=zCsC2TQ@2N-S}bbxszm)>)e~0(OfGzKhT;qQC#Rh=giER z%<%AVCL1x(UqNbD+NyD1fWtX;w?cciSkzaiy;(n}({TsK-$S&janlIj^=8T1+~(Z> zX3M`Z-V$F-*bygQZgA^7L9r?v!}1)V|5ImHk0q2wiCSSsz2g4R+u*(<`|Fd3CX#s~ z25TRx4||y?+J)@%Who1(tM$Kl}LYYCtUL^yJ;F12nvVgIYP)YM%tt&)d5`~0j3jb zFP}i3ciF!le}6qYo=g*;`6HRIMRahw{oI-_y2T@=5$NBF+P<#R9$nWDbaM7;que$P z*-iIy*NA$hstK>R4DP)hdiVynco}$Qx*=Xr=5mCM&216w#(jdi!C5j0AqeYvB8?`L zG9<`&BFOUx*Sd^)i(MvFAS%0#j)e#MD_K=TphuYgy2Bdovg583=w#PzdGDIdWuBK6 zOXo_Qiz-~a-hy{}q2BsUEKB5kBhXFpa~ZrRC^9Y>PlB&f ziQi-@O#+RMge{^EEj6~bx5m0NhyJ0pFGX$NXs>SV(+_lT>15FdV%hXRW6H(xFx;|X z>%1t|%XXtlZ?sBuF1{gLB`7ZGDJ;(s`oB$u*4z4l4w?S^>B9$4UcA^&@~6+>s`7tk z|9GB+DNM}-1Uk4b#>15;G6({r4JKw-9(y;z&r#{r`|X5TV(q z-W3O4JCurB(N&w-t8xK7G-~5tK$mmewU^MPlOHdHp+>Q|P%g#=uCJqPxbDkkVV!bRmJ2rjj z@R-hhj>8M2yWC;*B05;6)KQLyV~*p>>s49FrIBnFwbd4B&n|0p+*K~!Idk=5QLq{v za>M%}+0Cu$cB44AOb$QUVtkCHIK&R);KUkx>-$D?t2-WAi^)+O4TsQRND{SlHW8DV zL#bvc=MT)z^th8hPomg0-Wlk=ZE+P@e#<#CZplNHfzDh;mFhM9Jg?K9t)J5jbakq~ zy>Na7V%_k%=BgXfO|c&CC;RUZ`EpzwFf6moWuQa3Zot>$1jQgfn?MK+hGRr+G!c)< zsa$6&&LX$Hvq~%JnSbJpvIAZ#YTKl}yXEV+>j%1Y z{O;|OH`;f$%U{YdGU`A#pb2k_L2xFw${zS)Lbl6UHCUj>C7sG95JCfDGDno+xsYl9 zT<%X6{DyNRYM}3$RV|`}$0EL&?=*V&hIhB593XeM* zchbq1S!IitJzPwVGrcD)!-jarDRDt?GaN!lTXtUcKS3;~Tt*g%T5c>-d7mV`1bTil zpyz(M#30BWWiOpIQl55T^e^|@YnxQZO^uGbeh(hTuk*%TYu;oyvuxU}`&Df&cE&e1 z$l35n+=1@Bt#LM;b3B6{lTjQcgV3NE$;EVLP2DipPxL7g9R%EHK+pZry4$GGL6!Nh zTWh7gq+CGXjCDU8I00d+4~pHH+%3lwAZ)89O7C`laYOQQ{+8_c;j1=>Ae9G z{S!9a`z?Pcfe;!vzwH!H&n03I+Az;|FD{1Dy#zX#RUBLcml>rMxj0TGS44RQQr+=~ zICDF4{by0z*F~!3W#wyjayf221 zyTY#R4RfLgZ*$8u<=zyWBO=+jD(wtFsQ!Q z5`9Y1a|dr^FWt9>&Ybq|&<^d{f9g->w=)`wIZXZh&A2-5;XQbxnbnqW9=x0Y+g_sE z{08QEe=XqAbA$%j{^^P2LOOr&z-&yNx=ewDD~#wA=;48$n^c$RSC+gAyiuWdRNnq# zU40aNtES&>uhT9{B{!fzkFnz(-h)>hPq(=tZ%ma#;<;^p#P##ODjbbXe9a(MWBN>E^L1jxy^jFc%_lttU}wlGSHWP6t#U1Yq!hJzQWqQ_&e_5J$T?7 zGiP-MI!ui{Lr@>3Q#j}qArz}@fpn7&9T|62E70cydhSDuPNkXGjX-xCCLb#jwe>9f zgYx`Kop!}aPK7F*mU$j~$346UZ`&{<*5q<z`H_P;&7z4p_g+Lq4kgpPagEE>b+;=t82Li1`p9l!_q2=~KXJQXrl}4HnxBj`N73ZIqXfVd8q`DyH`o100aS!Ri zi&z&{&R})kS2I{n0FktN?w}dy;^>;}u$SOVd#pMR4YUf8-fNt% z%1d7qsxI||t^t!XWp-M}UEPB}xpMJh@#4zWGk0(+Tz{oglL>?nirol_VmSB%L0%`& zK_;j~f6SVp*{aZ)Y7d;++Rv{&ZEgRyT4}At)2Nty?fSi)T%e=)9l*VC2%#A0?okh( zU76A91A{|B+v0{!FtMdwROrm@Y`4SHp(Qn|f3HO>&VOt&na!?CBV)4RzNeMv80d(M z4kr*o=%1J+QOt#ZAkaRzrY_OntwUalK$q$1y1rxRj&HyWlfl2f^`q83&9~OX?^31u z7Keror*?YB9mOR%oj?eo80hb5JC+DCryc02-GL?g8jKdip#R46X;8cYkqBU>+{Cf&RGAl@p)@;jdsJ7E9VvJ^LPjX9Ws4x6btmh zb`VdhA%tS1KZreeB6UhT(388BCA!$@zC=68+f*w|=Buw;&X{`e`ueGQeREsAeri=b z7gCkWo%9udEB^P*c*~II!rr|jN9M6WA6WC_80QF~e`}aTF{XcNO--Cz3og-*yHrUo zdCSOVt{bVsmRzXzdsl7Wod>_c(xbLO2V)a?EYJthJXYrjp?_{BO}a~G`x59OCHl~1 zsvBXAPGO)A8m!KLv3LKtO$1RGz-Mt| z$^YWmiHRNCaT08Z6coBhNNLhgafLLTfFhzmNOW6{^tzb~Ou1K}KwJy0$Lkl5XV>cm zgJXZc2D3Oz`Q^Qtc>@4jp&G2#6{%`L(M0c-vP$&wwWr`F8KaP;O?X$%BWKSXg}F#c z>Hnm80i6SVYA{Bg?-8t>1HhJ;1grH$s$SthZH0BngykwI~65)|~}$Q$UK=wE*()7(kM z(t6>%yPb5H!H|hx=&QQT^rxLb_e`!)`pJWvyOaXGG}U_5MV{|G98?2<$yBJyYHrSe5ime6ZcQ zurY2(1%Rz`Tn|HniD&wLqAF?bY)0V7?G3q#=nD(ky?Hwk@N?; zJsZPci~hdrRG3a$!vE{TqdO* zFqfX-l(m=LP?Xf{CN$v^N{W2C;yh0e^jKwBYX-1unmyd81_0aV^mUM>b6T)tpRH5! z1iI67FW-h_Y>x6?cAM-eNZaVwiW6OhOQ!96rMYA8Ui_l$AIvH8Kjq7F>+F174`7G9 zzBmv709$C76*u*f$aE1DQxm1tCHmd9t*@OxSD8a&EKOl%CPzu5-o!8D=W^v78dCJ7 z>@?i{fzA$)>ARzfb7}yvwFYaCac8FmgK8x5B>KY}DYfFmX&kRap_h^g?VTRWTjMB6 zasI03r#SK1eK~i5&Q33?$n@v$zK!qXE$rPuf0vQxOf18;_vfzj3QOkzu;qrGz^6GXeqUR*o)Ec8 z^z!44RLS6WM$04Phy!0r(uz#Tocfz+;i-)Lr_cF6%gFOQ1v)$IBh#NP^x6U7IXE2$ z%i0aGTBS%r)f-NrrziT|brQR@9O%Rk`IA{(^VX3Q=*x@l#)g0p8d^j;S%x>^T;w#=jg~waE^nUu z0v(yYLoO%S*A4*B$Z?Mkuyty~iimBbL~k}Y&{Lc4oancqTAu!D=Cfp^qIqOTsbgo- zBAnwt$B*j!a_)vZGJRK^91YQH2Y~0MHJJ2k{mGyu)-Vj#H4#>3)7>h^-pi@9UG2$$ z*y(3w+*tPC!Q!Vl>OE)J^;Kq`GqDoJ$UPU`2{yt3z}~0@>*;d6lcVV#`;e@mE3J5Z zoLw)E={VVE6tAbRKw6T&cz2WCe(%?J&hwQGcVzl5Xx4@T0APQdb%S*@TVk%1Gtoc& zlC-clPR8N{wsc4`;k(E@3YYQQBDZ*tKNxo%{^s%T=6Nz27MZ@K>h1G00RXU94o`yo z>I1=?5#FQ```dzPo3BEkCa(d%CRxd`aq%Pk?tJgQ^W&f>@vGqxGmi# zx3S+$DErhwk&?>2TlLpxU!MCL?gh0bm<_uf&}s*Oy_Pr8dtCVBTt??a|NJ&2qR>*> zep(vbPRZIjxno|rLg&414x07tlOPYx^e@+stMZ(Q;u;s#0M>_H?EnCO(WsfH_PG!& z?=m_k`oqU?3C(JCrfC-QCJyMuu-7``^4<3?E$2h%^!)Yj%Nx#fc8Yu1{-^G%mH+_Q zca7E6WoIZnO1To<`t&ml$r-+`=Yei?@-H$L|C9F3jj=s-rg(2VpLma=*`v3g{du0f zfL`3e`B5`~m5}BD0BdPXj@sk?p;)cbuLqfqPK79z=p5;fw|vtYQ4;9Vu6DV~JE(HIyd`ot9tVD1_v*>wiT-#Sm(YAFUCmNkCM)22zJondHpf-`kn42P zHuDn{K0(CC;(p!V{rfy;jhEw-05+_O+8hA(z&K!+2O^KdUT6JNy`{W~ZhgCsBbS`^ zIFWgJQr(&Ou57HE#nG>pP8kvZ@sZ>FpXNC`8)1^%2Gy$<*wYRG`(PA2^(yy-@=3eH zVP5SI&xEUZqFYyQ;}U!x#-wR+`Wq?xK=X3D4SOf&E$zJ}{ms{Mt~_UABPz#ezHdbH z=p2`#0>GYF?4NfkBEN&<;o#+9bey;TaRc3-=p5;HUw+6?C1I?ji9_7j8&z#~N6Fr} z)B|oxrUtr^H=oMek>|7KJ9f$aU6(z~f&+m4QE?V<4@EJ>6FsX)f4KiK&FCbziOX-B z^ht*6$PIKNKBT6UCZ7K~Lkn^K|MPs-e8nvLtz-l9ikX8 zsYVlZ{QgL^3Wd(DQ0S(cS`{=Da$oHTyewNi|ri_Rwf!CZLXo^RK!TE8sx?d)xS&BbIp!2Ys%XjuPJ#BIy* zqbVP{o|{U28K>kv=~C?CVpjl28aC+N)K}s>7oFUVM^=r^5#2DB)?a3`tr2}`RotS} z;dXt0%{xu&y1v}Z`ByVOq&?s0w41o|-T!Dl#QSLgkVHAjWywA0qEphz?8ewm=-XD4 zO{#65PBm!b+<1O9cizoky9s^$v-HqwgmZn%Ff5fW%VV4#fpnbaqH5 zlj*g&dj4uW+wB+H^>;hy%WcqKU+f<~H;nb8(eH^<&S^aV_MUI#ibJfvd#8#m9B2oC zBr7|7$eaXRbi&&_{aR7YnX~Kq(x9)h(YE@|i?P_czMb=YM0$>>dsk7aI06R%i6{Ob zwNA=6|3zn)-3f0uzF9q+tQ%jK<^)dkeaDxZr~1O?b#eWB&b#EC>G}5ueWTE=<0W-x zmtqew2LM>>$h2sgPX&NGeMl#d_oFxKo|%2Ii8Zv-0r+>`uI}eNBNL6D@1l-#a)(qA zlQ{suUQXMt>57GvgTRYU_yd#K{ngvzh~2?CgSotYb@T9a$9v6rD0^<|v^6Zh?_Ja| znF9c@8dY&14ofL#ffU^}>E!O|@2}Be)pcFpyqT_>%$8r>PUrj)ndtR=A9dU(C&^kH zFPZ}YgaA`?hasKt$NiiAy}h|!Ee;v0(VzS2Y{HX|avqtUn{vgzlGqF7)`NMqh*#49 z0K&q#=-!h~CiAE9cs!jxJWQwK@$B*6g#Sp!!+0Lzo?9BNx{$Q>_sU?{#+zvX0O7(E z-AB@W%CNGL?71md?u%+Nj><_wy@eff0Dv&!oQ8CgVV$?;Y|bOG+ZmU=1W(zF`00=wov>~1Ns<}(fqup~$p*>X8 zAMtQeQ?xGD*Z}|{f+@Od(uwbwlWg32Zt0Y(bve>Y@Rx&T59inc0HTF0I>|aVo%m?E zYtEzI^Q}g^FA9-+U%evNt7RNx2LOm7&Uey@OUYex9>bnna`|q*E=OT&T@n&Nb=ej5TL-9`l~lI()mTiG$krOzcuk7PTHi4ge5O$BIs}4Bs_J z&TTrSc5hvF;qvQe_MfVt)cd`12_IAe0EjtnNhdyIPSQ490()*td%0WH>WVC=(%Cl0 zD>Xq8oBdw5T*6^D0D$D+SZSxG6VEMo%Xz|kZdb?`+pTI}Yl@;G%R)_+B)4`;lB(7O zVXzLk)~~j@5vi$0qG8hl8|nsySqb}0i?SHq@|JWaOf0}M!E!Pq!IDu zzMtoNzu({S?H_v|``XvK&ULPJu668+R8^LRVN+lO005JhlU4@+bO->TC18RdM?~x+ z;vY}$R24O40PnGX|NhOS@9*!KU-1E+LxB6hLqE~=^$pRuZNdOxUJhud0H!5?RW+a-2G}+NDiObb zpTFPy4(Me9T1ls;r+{G|U|9v&w*LC{>&`gvwU4m}h5`ufK1 z?k-@}09ZF29iR3M4g=0g?8Xf`o{PNEC z>ABCP)$p+Ei_2?01B>>q-p#EY3Ucy-&lTen(>eL2JA3<7TKzL~Kh(92rSg`%7luA& z7gkm`b#)Jj$tVwxj?)J8)-`r4EG|(gR*Zi84rr#Hoc^w^Yl&$n{B>~T?B-)*=eoMF z6&Ms@VrIL%w$al!JTUal!|VOl&R%0H%HGL~7TE}xSI5Msnp-;qZ$sa_)l^p1_enO0D5)9~)(%E|4dmtwJPQ0Gp3<)TyaXl3`7 zTEcbx_si}dm%O&$R@+%KyUq*8PEk|)Iw>(%1F{oaH^`zb<t2J#3FF zvfTI3dL5PXaJ{2cReTo`e|J2s8;i;<)q5CavF_`D2fcq-FxC4s&GDh^&RhQ@^7Y+v zO1`JxU4RR@l;wxauMQ1?%3ei*j-u*Hg8$YL!$SenjUoh!6`F$^O90?LBQGtX>GR|8 zhmzeAoD8x~j(u#>HuS!2KQSPLs@p#|?=69WMh)}oK7MUt{sKPQir4H^80NnS-5|!W zT_IF^mUq`}(PL5lf7tM?&JDGTR*!m(;p3}c;wy(YFKtxCn^^I?wK9!QW=B}8|S=a@!{&{w!HBb3qT9jYCmSZ!Gm>#rvf zBhp;$EltKSS!a^?F>s#tc)m#ceD-t$|D_H&0N&P+d9@QH@2W5zOWmSUr;9U_)fk5K#0Z{xlQ{XNCl$kt5`w=+Q#}!@jgG972n~gf=<^Z0Dm* zB!D8$U-ocw1GWoP_ju$8wg7r&2?#AVF|!ne7K@l!211KT%q;u=>@kQ-AI%~Ea5U+_ z3Bf@atcf5@i6`ZWpdr-99tsP9{tifZC}+v<5OU@+RyQUv^KB^{YV9-|@;TA=Yr-hswI+FYbDQG=4Cdf(tD z?MB?9+CVgDcVP_foR>?@Dyr8U7h>PY-*&p>nS;6-s;6^4S0@wa++9q z_NUR{hBf@$=kJ(?3-xXPJ4jrEQ;?7gJuUe3hdCCm*b-Ai8Z!UO6rU~>a|lJ_b(HY} z$q8M*5vvxu0eN^=uw#2bZC&f~K8tYlsZ)f$>6Q=C55{)Jz94Mb&Lf9y&QS~OZqLvy zPv+@+`?+*^q+5FL`Cs2t<9qtJ-mPu1taFM9|IH^hi_#$<3N8dSmru}I09@bLBn0}1 zs<}BKvegwqOqh}oApF1Lx35@p7~Ii?xq&x-Fj&(Im!|Bl}80+ zWnvYx1UP{uLYELbyZ+z|&@$v+qy@%i6I@8@%}sk2RbJk)-3+BI-r$GZh9swmkTb_q zgfaD7lLJ)67|lo-cDx4}vSQ|Q+!&%DOMna33{t3vT!sA`0+ehso}Fs_$D3|PlkZo6 zgQuVlH|O`eOpmjND^rj4NfmOqVy`2@D#$x8$L$69or(baQ$bGQJhpG}N)IM0Oq>U| z6}@XM=Odj}C8}kJ&0<@$G1eYb(H~Mp1>V~yS53E|bqm!0>D%h3$iGHnM&ShJZO5Gn z%Vs6dxPZR5rJ=REe^(Ca60*tmY&+(6V5v%i>()8Qcy)_e?uim&i5q zE*ic8;JLD|n^)_^R?M!sbPWVtC~t}vK_l)_?qc9y#un>g=@Xbjn>%8Xo58vxgh}IA zI-x{vk`1&Lo2?2t+i%Udl{_MgGO{i=>*%r1IK0O51YDm(d-wMax|x(Wa=;kMdD3W( z3^Tn$%-}jhQ!H*L0_=U&glQq99dH!CKe^$@Z%O)xDh+<{Z$%?!xZ>9jXq0V;8 z5by-Fjn6Cumk>Onu)lT2b)$Tr=K!(;_|TIoP2HvV5@t}nFX4gALvp9D9J1W^oD_tj z8A<>L)}c7(Nh`>ZLhI3ejbUn~{z5v%9=Q&4x+grUk}&6TPfeU*p*0KUXy2?WmEd^w zw{0#ftd>)Qr@@OddBrU+&oQLqbqgW>Aa6~&O4r|G$}+@B(6YPe>6Aw3FCG;FFJ``w z+ix!zsX5eEh%o{0H_vUjuivxbM6h6OjLY8!3e-q7iS7&~; za7fZ%lKo~sGd$#yP2zypPOzfOFtkm>y*Rnlp{5iAC{3*Ml2DmPicAng`LVcRWP++D zGNhBy3)>A(X9x#bFS2U~pTf<LEAT>>{_u5_9<1S$ALRF(^M_ z>i`gDd0L-*f3(CJoK#YW*X2QPDD2_{D`r2sKWVGS)h*%fDNg%Vr(XU`)MP6uS{}`0 zi&e0hP*>JXgS82X;(9l}+%*MmVwMaXv8rCL5uD_8krTZ~orW6+KGZ#YKNsihaIp6G zaSDnlUOfKGBWaDt2VaWfE-Z{!o93-{urp>QP8R6Od;9nI))_@-LV5{O$O#PBnMj^I z^PR?3dU^+=!6S5P_8ALUHi~$)(1Q4IBFx28hRsPL6;|lFhxg>jQ<6KT7F&qXqO91< zbAx9iO-cNsc7Cu7-WvUVU~{2tV~xQSxxYni@tOXZ{^YJH3_S z6Au>$zaG5Je8y0X&;k=o+G-s}A5Lr$n8Qff{BzN4n;uVB!({!Z*oz6ad9m{U6p_)B z);_9nhEHmq%Vr>H+RajE&Sowq@L2JvY$ya7#Fx+cB!_vr#Ag((z{-n4g>k{E@ZrJ` zG~yju4?3LOpw;`#r%O%gGpb4>iyj6NCnP=!!G99e`l)p{`f#B6)G$@{GnHrDmGKd>efoo8H}K`&joWBi5BU=De% zKN98ZT8ZhyCSDtJgbpqn;XO)!Q!|VU6bp1g2l+@aUQlB9k4)E zJVCi4vvUjYj~v#PX<)@B?}PZCOg|w8mOv$?G%jtUl2DDzoV@h2-@Kdyguvd_z7KSl zzOmOm!o{}&MA@9zHjM)OG6>BefY>IX#7|Q+5+_1+1eSLG&pr}V%gA>wmmho5A( zgAXlkB;%+S7tn^a!D+fMIgl(6BG@_&a9BQ}@@yB#;^hpF+QnI0vdg1t$IP=S^8vNe z%@_4hv~Ucj3QV_q!}MW9ufLRP5I}EP|;&|^uMjoB^Yu0+Y&j3MagnzNS z1vz~P`BCQV6*vt?Lf`h6U#fzItbIV$p;>Okhc4scxAj$!VjozA-m~WD&lOLphz5C2 z=Wb64HrzRQ$9+@YJPT1hKd;Du%!N*=RLiEQFx?x0OI5b64#*9?2;1%pX7w_){cXD* z>=d>pzK{C&QmFE7#J7;?MAUrKeP4vc;qg*hR<3z{AwVHW-ggBb15s@}xweH@gooaK zguGOi3kyzRHyuY8@h?KQnnVk---^cm-DL342h@p82^o7ldxu$b+bpUf@6KH8GOgJ> z>;Tf*gsi6srUkf7>`{9cQD!9L6uIHDExg}~h8FAnJf2QD*?4Fc&4-Zu1)IFVZ@kp+ z`G1Fi=HqSGv1DQQ$+^#L<80nkvPAKsmCUqDf70b2yXl0aFtfTywPt_!A)mgufFbnd#9`VV?prSvzvzT_~zZ?~cZQu(7PF!RU-KV50& z>Z#Vvx3*2^ELj4AzeM8HKzoZ0@5DdaU>-)=Rp&eCkNhxArsowSNy*#%OajD6q2)2K z?UUx&aac?|&}S7}R0DaON^JTVEwLHi$hPPPOu%~j8)OFhvr<{137dUM^6!k`uae65 zZua`Ll!-qXfYTpd^ za;GGb^1W+hLS2;A`5gUCC9#jv;)}INgLXQc^!JN}?y-|ukz3;f>3b``G1@aI&=$lF zRWXj{=(EA1%-hFl#0aL?r$x)JMd?ZoAx>76Z(2H8X>XoSTJ_@psInKak=53q=0k;8 zhY~HsuJH{Kgx~ZdAM0f3PazVF_CP(tFks++ReJcOE&cDep$#KPm9>?%t{-1VFQ)!g)966@uIWCwAR7D%`Xc2yWf?Q#(6tC#e1?lAP%qv ziiPl5)UZ8j4pH@}3E2P7!DTscS$8vj)PyDwDrXv;w29%8{PYy}>%bOvZeDhIbx1)Gk&s6@-pqC4s4d$N-`9RV^j($QP;G>I`W>Lq{S&6+nQ zT~QmH6>Nv=Fj#Je?y}pM!gYFEZ&M$<_TpTW+)Vg=t?HkEkM_83=>!}uj*pxK*+>Ta zl=vn|w#0zH%q4H)-lh?MBqz+bpl>tPoc=?N?^w<=P*!Fs!SGIt@nza@`Pt|4sSLEb z-v`vCQGVNc8!CtdPl@r*4(DojqLR{sZN&$|rn_AU9SK-8mMP$0^#SCw&(T;YKZZ6v z#*m66+I+K^Le;kf$ZjX5Bx9kVc^UUMIaP*$Oj>kvfg84E_sPtN^T~U~_X?g<*SkbK zUCQd9(>Z^}SIbR+4W)67dqyoQfg(YjU|4aL_bNKedFRv5q_#Pr*$B%xbo$Fb9=5sv zVa55#3dRR7c&C7C0Z?Y>hd&*Sh4e7I%7P#As@e*$g<g8rH-hr1VnkgcLgUA`L|D(D8<_E@yVnsYf zP)g-VDKImK7dw@z5!8G`Z>kx_*QCP`(CTNQ4L#UT2+a2Ml-eycHE|&ao|8R&zC4b@ zI+m-wVde1gEz~+MAJTh|CXh0LL-D3N<3GX*@88T29C{D72W;)HAF*oqPNg33VEDkR z+IGzUh~tLtUpU0Xo8ZTPF1c6!w+IBWA49vSBkJStb#>KEGlzJ4_Eubpu&X^I-~ae> z-fkW8Ppi4B{Xgsf3;Rpq6L5QfximT&-aH4#LZrQFI?dc9t>OEEB?g3`NOC0kZg7pJ zU#o+!RU+2@xi|BjifMy$eQ&NVcX})8!>h0D;3c||{6oHl!{~d&5bCD<{R&<@M%N9{ zul_!fSwK{+6JG8|YNM&gMI7;MO!^Ps$*jVk@)D~REu|_mdZm_Q=4C-1DFQ``cv|Tc=lTF94fws;->FF)*9~>O{bv$Uze{C2)Aa!$IG;Pli>0csHKEMHe~bC}UK z4P>BkUV!&^|6?4+R&*%8gidCkMTL0Fdss_J5omr6MOP=tINr7Xy^xfnn%Y<_@GPJj zGL$ef3y|zQs%Plct2fOlYO0tPWFSrPGrl*FV~v|phnRP$bBy=maY^zY$s^(5wgGPC z&&IrBn?`=5Te^S)iqo``o`^kowh~z@X?kDI;$#did2IhZ|HE)-sYHh(=e6Jc#s0VR z^gp}+$9d?4S*o`Zdfr!Bc_kha1ACJ%pv)S&&r1|Gy*Ah{LdZzmFr$5&$s*sPHn<7m^(a=&|7asFJ)R#AD_6WVd*aFt z&NOzpkKk!N25NxY?X<$EQEX5L2)>v%VzcNohVzYf&lk$RZAfNn z*TtyYs?FKY1UV%X)A_9;ne~!<4n?O}j4w3%{$HeKik8g5d4E0^>nZ@B)Wz_SHuu%? zLHCRHQQu>~*0-m=Ve8S)UA>=sUB8&uVPyV`E%F$W+4gg`3VtS+2mXc5e<;)ICO(%^ zwz$apXU-hP9jyT_SEu`DR?CGTGv)_Ai+8q%|4G{7Hoc)QXdcDF_SgN3{77D3XN_4{X!&GO}Av;4ZCc>ipk zf;r3AVeS14YMm<;vidIGDCO?QhFWHtB{kHH_$|k?4malT0pnDcy8<@hE*{{PfX+&8MHo_vk$jp_&ZCVsu|oP z8Uv9N9iu6JTO*@b<68!4MEv%4MD^F&$T5ozGwU#|dB+vGJx(jq2|KiU$EZ@WAIhXv zal_$U&B(HM*~#_MpgRvo+8>D_YvyiM_V)Q2dZfSJh51TgMzL{*LX|()5{k&wBr;-# z5zEa_vL%HP&_`ONVr_}CS5g&4W2G~c&W9#6NifdB@tQ?+67bO6DKpCZg?ufb7h(ex z<%4{}cP+(s9nr;g_$@C`_FTOZML-c6UZRfD;Z|JO>jD_9yu=WW!WT~=I^k41Yu3;4 zT&T9v{QEipT!JOOMcuK>Go1qt$p(&S+6L^$1!XLhzj=s|AjBhZvB>khdL^JU7Sz+;OY8j%~ z=-LK@$Q(x;*M(Vr3x?2$8_pc4EuBrRCG2hD>%aC-|8KlpG;qVJwdgU7v7kvGpBrt4Sx&M5xYqb_RhP7e!ROB>jFXFUQd zTh@9GyiWd@q(!_P37s97{etT`B{z3K`)gC7>E+Cyi|_L=TIyl*82to)mtVk(=!$Z2 z0w>oAGU?z=Zhqk(o-VRC_s}1dv3Ym+M1NPg`p&{*oxhUu$qY#$R*0WNowx>8Fv-1H ztlDPY+FQZ_ii%VXIfV#1_LsEtgJh~N9VlK1#=mr-#?CBy64Mrkq>wTn@ze2G;Qh8h zZNC1`mzeH<1+hS8lf!;{t=78wVb0ZbB!5k3Aodmi@e`#B6CQK7yw$@VUR@Ew$2*Is zHL-6;%%SL9>u5KWS$mf+u~#@=?5)>9hpvS+-`u8>s{{1QuugZWGe(G}`jI_3Zt-5t zvuth3PN8Ie;CJ8u#Y0VnKN-mk!NTNhs!arB9ffooLf5VZ*SAWJtkJ|MBW(YG$$BJKlO;zBeTQ1miu)v)y= zH*>u1mCN)BLhfdy?PCEC4xkJvRElWMCUl!tbIyXuHiyOW{@0NjGC}8U+4vW0!ikYXEGWv36Q>LB#jLD%2=QN}3Qkc3v4;`hyLgO!V6 z9G||?uK}B6_?tH$!~+5%=Sy2^M}YBPf0~i*BCzAFZ;mFxB)i-D#nm5of4$|n+p#r= z3js?6c*Krp>Vt(VOP!7%w5%lKbQZ1>Ns=WiNQzAA|;3X_lB|EjQCSC;h&FUGM7+~5wLvZ zD$Z4N(J>Kp&mSt=)ye_!DV+**tk7JVnzdhQ8YwECAi@o+CD$}#8G#=y4m54(E^Ekt08kZ)#*6{PE>ggT0@YMjZ9UjDvWU?v2o)9~D2$B);E0KMk z!)(>EZ`YBZ^n5I^B~%gH`0%X$YXhHE>$QJmzNj($KwGuWRp=8$v5! z>FBY_1cdF8fqfUQL2=Sg^)#}0*JN!QUMbm5aW`<7n>c|V$)@xRwYL3g-<=2wc(}jC z%Uz}fYg*MZbIZ@>HH!jkmay4HZ8F@`k(o+BReON$n>K4c&muS?Hb|E}0nczb1_-S{ zV}|KSN`3}1*&UYgl);$Uv5D9GJlOLNsl=*Uta@5I%-F|uKh2^Su5Onhk5XrOZ2EmV z1lUq%*9EE@=tIx&{0dLnLr$nqNu``lZs$vmLdGxZ-`nIap@{yLkP;emc6_~UL*kjK zdr!1~t!#xYd#!T0&~I~ec9MJJ-?MIaVWiLz?teFXRO(MQ5U_(?3Wo@n9d^F*}up(p6WIXX3-rxOuo#(Cd98G8G$ z^UHia$;=6i=eqCBPGLxFYDm9fN*;^GyV!;^tzB4=XfiER{7th7yCebJ+z#wbiSYF} z_W{?+$G_2>C5Nw-kMI?IrXfFfA;%et&>`)U8k(@QaKQrqXENmbBQpzm!7->PX+|#P z_zs*0Qo;!24yljWr|4;w!FG!aCFW1!kTlYVrB|HINRi^FV26bx$;6B0AY$-C$*;EI zNiv=(F{qc=;oQva#Sehdbv1G41D+dTf;N37=+E~oh@J~EPw;5l{%9Li=RNSn5v)cg zJLr2l!?MWZF=Q|_H#|8fWveCeHvid={Acax&+VY3r==2e^iaAq!(}>#x&$0$Dr75|eQ@j$6Psk@$97{sXK?)3L``^4JUF5f?& z%+@bQt=E*hzWRG6Q3+eH8{qe&YA;U81t7SD(7aeJGvgLPQMgJkAB5-bl4j|wLo%saKpq|L;P^N}6l!CO z9fB*^gKz=AK<|fNYaTQ<<`gvAhavr)@_hTAK={`@UX`Yp0j}Yo4Tu6}6tcdlxZ}k3 zNMeOkm58Gi$@n9K@YOPkz}X4F5&-Z4aFv%eCmFNh(fe8aoNW{UWN4_jIl|s_cBMKRqy((3X@I4q0Tgk!~98Afir%ccXL;ZogvRKs9_mD{% zdXpaN*gYAg=_zl18o=ifxktG`O1y%Qoc;0)Qtl4!{azKoN*PV*Za382{M!cfg9z>p z?#tT{JSY=4VA^0v{0J3z(g&NBPJFTWi#{i^3n`|yg;%~(oX0JIApI?72p4#QS;vww za&19dN(ukWwcc;XxVFO9gGf;#z!o?az=CSJ7Uy|C!)&*9kh_+t|T7<>Np; zPN?%T;u^I&UhqgLipVhZPyRnNF!2>^&s$>sJ_$~SxP~R5VUlDxeul^Qz}G6RUH9nW zYlsZ-3sNj7=WC5TV~XcOv)d5WldtZ@01(Km_Du36eF zg)Er*>(AeJzU4HpwRtqZVM$>^4h;q8zYLRrzhn;Q>UVnyRrbN;dSFD45KYku=u7R# zgFd2hj%ap_VdkhV%D9t#w!yf)rL5PP;gr}5MHB*v50uPuCV5d^$ili*G|{yc&&ctK ziRWm)=+Uj&IuFbQyPi}L!QH?^SF_Sc%GO`>b?fJl-)~K5(@}tiz&f#0o3uT;kPQ?T zUODCR+XtGwQjCaV)0&Od&me079D_eRJrw%HvNf zf!!D>;2+_KY*eqK2K5AS$|{xDpGdu2YBEn9`Lu*^Ul{Cx0qFfG|EeXs%O!=!LdDN} zkGV8|F?>LeH9_yUM2?!nOcu&?YlYJMmvTR=4Wz};lgF1$2q1#c`{BExs2*6<@ArBKx=!k z=W*}>#PYR;Ts1KJYhy&XUMSOSXQQ0)0DssxJy9zNfg-cHXp6lwHDL-|#S4yWUh@!t zlazy4fxoiQ=BCwZ0U>C~_O3-TiyJ5uDvZe1-ML)}O^-QGvpVa2)hMyi1kdI0aztnS zaj8WuBk_kNm0xs|C9x4~YV${oQk)yqQ~H>`aE#)eks2FNy}oV~*ah0Ld+I%9G-a{qXl$*wW*~k%kxuv#AQqAj3}u zu0LM6I(&7Btp-i~j?xD6vh$OcPQ%Gz0W|M;IT@o1f`mLs{>s#Hz9PgK-r9KK@4J}R z7~S2MQP4ze$j-Rc#o4&MhrSa3k5)jn81uw|=4CtPNiYS>M~wSwpp(b_odL;w&Ijeo zu3N=g;?Aw2XWVe^)>rSMHb1>=Gds}o;3$oQWUQsq7agf0R=-i^Nq~1g1&idr0dGrx zy<(j3q7Zz3)F&yW!j<$vacoFI#2WL{5621mJ~qf5s*ZKfXfllznts>I&8%*Os=IO=@+N&j2kg#6z2H?ljcSMsVW zI}7r`HwidJo7WZ+Y5q|WR_l;!ymmq}7O}#x+h$S1qB4oM&zsGD35|b}K;>9cI&03S zBezql1@8L3{Sn)kSmtN#!1ZoCo)0r^;Dic8tkgGikgZbf)UY57v8&oS9AW8%@VlBp zP1Z3U5xx~^ead*mYCZLav^ZFI&=zl};_L%L^-_Vl;0#OTOWX%KrB>u0?Y)czNb!W# zVyVX17OExkd`ywGf`p|`x$fn1Tl$A83*~XV85yz>)m&wRO@SV>6n5+4k{d{=WLlgw zRXYnQ5$hMZP7?SJ^Y-F^nNva?ocd+Q{j!gi3LJ6`HLfTH5EqiB(WTaPIx%EA^_gd~ z>Uh2Ha^@~;@fd7!+?kauZUSAJ<*Qe3g5D=EiZ&o%->SGM>dW-71N{@f`KzD(Novmx z9Le%OmHJn+mUSDNPy^!{azt7`tvd8aiM)aO#`5?Sa_iDIm_dpC9T%$OiCGlkGu9g_ zk(zhmAzy4O6uN20{=&gi9AoqhjJ(UrLq5XTa~V86Ie4~If_#kxYp0gHaIt^JYp71V zCv6kFKh<>OmlYFh&b7f$C|}Kfv#l5cdADgv4IIq*RaeNoenmDuVM@q>BbQ!29Z-Ta z(a4f{pe40dlAR^A;_UxB1e^-Xidh98nI!1zhG2$Edh-~vqB8_|poaVUEaoZ#ft*)Q zT7{3d&I#~0g}>GOTpRjN&Zyt_S1{qJg1>6Bn22fdeeTgJq2Z&QKx;H^X_0Y5$?B1f zjB*-Km%bnBE^j9YrnPu&2hZ*2nWKq~^P*U<6>;`K+5;I)ygdsJcU`0Auzf>9#rYLm zIL?C9Pl!=D>o}$f5hNvI3O-YJEj#63cz8hCal_5V_X?fg z012jZaR_I`pAoFHMwQnpP8j|TlOC&oCQj0;h$>r6r_d)TtaoMgGM4-<{=oOtz zU~}on^XcL3%@^EoI)$qVm~>q)=Mjq>x68zhgD{1$@|%C7sMS&cQ-n zJPdP4-2paa_&>!|GlnyQN1bS~$!YPNXq)6E*!;1n+mRe6Pbum3ErdrHzYZ+k<`2Wi zc42*7R5w)S4knQdU`hu4ZHE-4iTd7TB<2cy!IVtH!Q`PgdJwk!f{;m|24b&_{RTsT zC4!58-I8|v98!>gT0ozVz$JfECW z*Uf#e$3ja$El;shMB9pVOl0TEdE5*wInqHt8@P%}ZH&+op1!nLugIY{$TDJ;!eK_F zF{Jaoc!4Y^9qNdDV1=_DQ8)z1ntJRJz0+7j4|r>g%f}C=_W%=A5itnV7}jZta)7hJ zWlJB+pSjJk+I~GBX8}(NUFa0jH8-L;q_}l`{2DD&ZDltADxJn|D4sF{TN>-gY^i$b zGFN0L?CCOxQ&UDbb#q3^chfS(AHtK67-q?!LeTGV;;9HdI*$nh+oBA$QZ{d*q9>(4 zTA?m#rSHfRE@Q|{Eo-p7v~5y+LSU0~7jqHVG6|qXt|Fx+`{51GLp2gp18)4;uOy#S zgJ=5~!6c*FO>)qaP}N5BIM-<`;L8(r{PIez1BJu=w(3;ka`hvqlO>!rPVzhn-vMzp z^70G$yWMC9GVLbMD#z7j*qEisE}gPl_EflbEMqHD>v74MmM$kE(})hK$QJ^NEQA-` z%-DT`4y@7+F+B>;lWnlko$PU?`6uP+XlDW>=5N2WsYW!Kh8DJck8pTii@cUv;OhN+ zkx#h56|&XcTV&GWt|jdETCVoLYXja|nQK~+)vuQEJQa=ww!$V<5|fuoRDYkYtMU_2 z3X(sVpFp)gK?*p>gcM2izP((uPW(_YM}kINw_mQkOoUDY@BZM@y=iEZh@)pK5f#sT zL&YzjoGf2LXlZ9XCT3hcte7NpzNQ+aQsQbQW;oHNSPB<`dCYnEokm@HWbz+mK7X_5 zV0RcfLYSNoDUl^~<3S-Cq1&#`ZCGu6hnjig-My|<$o*k@HJeQ~&s}ZZ(eH?#XEt(i;|%kX%NVFQ79p|Jb|`=FYO>B{3S!KVYtLlm0HTS^9+| znfh*)WaL&|IeU8{&^7U!wE~;n+MmxNt|Ucqan~QzfxkzLeECG8{4V58pe2Vq<$z?~ zxGg5xpR>7bywzTwWh~mCDK6`LHMGo9lrm|F3Lo-uwgh9{B99U*S;AjcZ>i1`*{^16 zrp_#B2k8|+m_SEo=R;>|%1Pl7Rr1ApiekJHsm9LAu0N}6)aQosbK>>C9Tn@o@a$87 z(PL9nwqPzHl=$Jl;M6Z{liKLz6-eP)aX4}+%(ZIIdxy%hi+(eVL!dPEBQ)E${x8+^ z+@;G7gw7c&)q2XY6JC0#Kd1cGS=f}Sjr(b)VKS6St2igzSwQIVDeQ@W)G+!n|I**U zcHe(~GwuX)d2iA0iXh*4gk+y&!q|{WD18?f6KEmK9o@llu2-24e4)|p(ZH04y ztib||w7AZ=-BzTB(L3q0Ee`PWSAVveirweT!F|RUlh>~tc2Efvrh-oK>$M;zbm`h9 z^xUgIQm1FCF{2Q;Z&|JevPl@F8m_FLr=i)B;R{Ua`n?J^2{Zu2AM0Z@V^=zFNnz5wo+J1>yXB8j2_N}s~$;BCY)=GA-%i+6yOo2{ILYPGWfod1UrC3pc698VmJOw zn=f&$WpIml`OJRC!%i0v)J;fxQO=Y8vA%B%7J&=!_At6TE{WFdR!rEVPOZSJY(^vk z(|Gw8rdFE`wIK346R`ZyA_CNe1U(e;K|ia!{b==RM?v&P6;(k(Zc2G0HD*sX-pxmU z5b{7u{Q+jcL5Xi1LdxFnzWZqK@Z_xfYR$Oa0bs2& ze(}0CxE{H7_~~mjp#;(3N7Ir8y9$R~dYPBc@KVq#(;~X9l@}z5`XrxX`S|293|S?n z+QuBj@9{l6abm~TCV2pn@TXx1&yf_@x_Zryy-{Uw_Ouo_mvymyz4`$yIDT0;;gbdDs?(UA-p4C+_29 zyM?}(W?Sz;>;)Q=6yUYa;kss?l@;&;d#$t_&nn|dZc#uHd73;p)r6H7K7%t`>U{_J z_mO)TG|$%$VVW$U9I$J&f9%ly*j9Be@!ZaM>ja>Sl=33JBV;tj*^;;YsRVPX*pneI zyR~g-F%-bNAQOwKz_UT1LyAA7iIU$dpp+aG(Oa=&2nZVfC`J?uRwps&iOc)$P7?i( z6{3{1T-pZ%F){m_7g%0t(Ll_jd~rFwX5wBLrg6!X_Mmp8>9^n`7}8WWS<2lsTolYa zunSNzd!vUGLND?~4e~VWqsMjPkx)9U5Aa*^n`!jiBHvf&mlHFp` z!F$a}{Awh%8Wf~UnG8vwuwf${u3!|)=|@g9$%D0!I|Qy|(VWzGeADp9UWd2-Ol=ed z&M;L7CGuWH1#~j*p##+HWzsg5-+EF;f7i#aRfPVUSXCs@2Bk3_^1L=NGH6!n7M^iB zPKeC_p_Ib!QLD+LzLB4Tqp4^$KLyJom{})(jw|=-F zj6(N>DDUBzUi^?RvdGhw2GA|(Gu<2@q8Ar)vHc+`>JiPtN*Bk_DBw(HB*%cK(-FY- z)#@kYC1{k8U3bzNX)FLE(xSA9xw#iVb&XDVmK(C26T3&0SAsdwa~g&yW&OIMh+wcu zbH7W70_Ubca@3eU|@(jCMBIohOh)gX_go3|61owaURC@%!3eERSzixLDhAW>rD~c2rz1lMHXBxmw^MG;Ks?|RmGZ{MsVovhTt=@u8fj)m0dkcf+Y!9aR?=zqlxpYc zbYbgw%-+?$(~4jRzYylf8JScfX2MUt4%*~h=tu9-CJ*J~_CHfKnh|?a(k4B1xVS?j z`DVU$?3a|5DJyS{WNtL5vdM^vaMunjOeOz=({^2j43?4ui9*^HBMXjMYP9$Hu8A}w zswPnsnmDjMukd`M^z@JZ*bgz^0dCk{0n&iNg?9>=l>#%u@=*40xv^!`S6NT&i|?rT zdQ86W2zfAO4aMG|t0=ZzF$i|TS>)6^w~yPKrksVwBNG9x26Zpcmw$wK91`vMrk1_Q zLQ96==ke0sbDHqY0BRhDysGoc$TFDb)3m{_fv+$-?rhY1}0JKVo zbjQh`K;74{y^oNKa+u953+Dup35rJYr?2NSQRU?B$@oY*rlGoU=4TL(%uSH#LBzW> z%*$;08CF<^ayNf(AYG0kqw5N9o^=VTSTn1+&^Gdv(%<@UpAmo1HE?rt+vAv=TacWH zo<)!tPjNB@lb=|c*{6>`7~d9ig5L9OpWq&iL9e~M{*R=2hNH`0^H6&Ej$gg==YKj6 zR#3!k5HJnRJF(^`Bfk_0a&%}4_VW?NH>xL)zzT82dyZv<=04{DrU_$rq4eK7q76$j zlUX;&6fSU4y>=yS!)Nd+y)gIRHmCoHR5wj4Qmu^pwZX01^kQ9N{%m_&kOus#FcKt4 zqvPQ_HW~A*Avp$XH3kELka~Q}WUv8wH3+;^AIynxBK)n!&UuJ0sQC;Xg8GN3EusTS zn`6oe{>=(g?v>gq8^Xeb6q25hnG32Gzwf^L%e;i6Ms8l)X1o4f1{(L#A?pG6zUkrqSbm8L(oHf{HLlNmjI_hI-d_qd zaXVsm7^aTKI>UK&96WL2Gm8*5S z?z!R~>+G4#&=xQA;L?-{E~_YBb|aU#De$6^u47_ZT4nW3^w5z;=*)j8%R3=f%0oW= zjXA#0nGJgv@o%YhK za2Rn|^LfM#*$|Kms0qV7F2x%V2OSEhD#=#o&3R)aniX zFQ!tIi?xLp3*n2gPiS?*FVr-h-P>EOoYto{PJLY(Qg`0eWf4M(97%+U<)zRcN(wS^ zI5}~5?dq^*NWe>-E7_cRBysuF)6g~hsLAMhmkErrT9Jep%#D}2rVekiQZT3>12ZC~ zK(OCYB5JmRd_?BJKAcd zV1^286zckKZMY9P11U*tglNVOUkT3mf>@tG(Y$SbFXk(HxhvT10>b+uK zqT#=P3;G6@2<}GL)l1Dyp+8(foy?f8fAqi(^fC3(xWzqJ>FIOGA$D8iyr-GPf=*`A zcW9I#EFZL@RL$?gHiF8OP1gS?gPv93sp;^}?Hv%Inq}jW^?p#%+%UQcPLC?ZWp8ZZ z3S|>SPb=t+w5XQMRy9m2js%(=HrFcDtY4n3G_rO#+ z1yI-xPCCCocAoD#lA#y0#B^|Sbsr7OnrYkzN2|S)eWG=(L6sbR zDuyrADHyb@PQI>!{o957=Cu=?e+RPJ3i{rch4!!tCwK+^w=MkSuMvboNMLfCMbZoG z+XC$vfN2{4vT!-$c9hm3uQldVO(bs{iAekxU4xm9Om6`Rd>m2KK7P$RSKOBE+qawU z#h>~y#!5gncod~2fUkY$qo3x_m$q}|@@jeX<652F>Pb-VZ+?AspLjR@W39*cq(z(V z;3q9m15dE&XMLXWbIoX94{cUtu5gtbR*x zxV)I|F4w?En)PwCRBSq;E-jQCVVdyF%~NKLp|a7T%BfUTxFTE~n?V-J%P&)f`wLUyCA~p^8tkN^2ccV0_Q5 zjt`QDx^W6whrIc@nP+K>`xdS==(G-f1=d*x@KbFS&8QXZFEgqgTT)@=H3 zVEw_@bEZ4J5zWlc+7sJ5%&L8kwt;^TR*2Muahv1aNh9`pzvpoeQ=LEK`2$n9&bQbx zLD~L%$PnVOu^^HE$y7pVw*bpVS+0{_j1hYL>3Z@QqQ``E^uRpWD=(YSJ=N3*KjfJU zsD_TBnEb8lGv)C6T-&Clp~$m>YoW1j($oJ_)ptiV^*r$=xg@j%0#ZUK2Bg=BG^wEq z2uN>ILJ{dj=^!Klks?j$&4369iZp5R#ZUyKOA%1OAXQNTDI(3s@16J0+du9(cXwv@ z?%dtIXJ+nvreQ^Oy@R5fl1?>XIXP=}zaoy-$4fJ^u-eWwrZ=kn-0)PmQfR9dyfmfJ zk2I&>8;iBEvdQEgz&)JDb!Icd>tZ`i-aa{b+^l7?5 z?*24|vP3O?c0<`=v^cc06e+4K=Ij0OccC>rPmo5zp8(_99c` z*FecGqwA-42M~hESn{)!@xzozVP6ULu(ckS)w0Cip}qxBqtqS1O91W}2y|>5YZlM}=y{M#!IL8)@5``X-ovd$;ZE&tm+PpeUsqs{bW!7% z1E@@3nD|b=OoCl}S}Hw|-JrV*GuJ^8TERN&Ox!T;+YTSLL~THIm~A((;%1p~Gmg}dSnFLhm>^DTT7xC1;n zuN^~>XL+md0O8$T%^fd{{4o}eoc{jTpItP!F>Iqy`VGOPyOrn7vN^|N&S^A2Xr?%H zt}4*t(KQY0G;E;Y!(0fw4V)#WbqAP3Vk%LGwqlg}4~KPwY!AU_(OyvPL4>FEQs^~; zsvA{5lA|!?$O-Zg+{#TQz9C&yH4^I4U* zbkbxLnKL1fT?>YUHN0ROcE0EA0y?PxeN?2(6vxDFB-k^E!uHDPxS2=%c1v~rDKGq2 zzX`+L54r=J;p`n9dk_#T+-hi2fDf)x9Mf-Q&rc!Xf4=W~?EO*mr}mWMxwA4jL>HwW z3IxIjdNi2EoMP(}+QD^KS%w@X-4uTV9(Ms(<$@F6$VHD!2IK63qM6)E#|k*qfP&)o z4?P}Rn!0%$OSBmBoIDo2nFx3+P~teN|IYfiyeaf6Y^QrGK!Y5hhc&f>o0FBs^N$1J zRR^Ww1c+?Tmk?`b;1qWru0k&L2cA|Y?E(LJwSd2IlOq5S9TFz{lbeco7E#AEzqIX# z-PJ@9INy{j;Q_l z`d&~JlLtW&IzEr{3EMlp>gIZ&V6E@TC4ovl7UM^#8dv5~u> zF~6#A&)kB~cOk+B)bY4eLYoc~dbu#^);sXNZ&j8*WZshn23)FwcEd5In<~^!;!v3x z^K2a0PX@aDZYJQwF6UOgrG_)13luuux(4!0Cx+kyD=?5Nn6((~&sbN*Kuyc<;3(`k z(tkXhb%Y@h7`^kD_S{fm&7)6*Ot>G9(_yiUr#Tf&w}EwC>OG1qe4`k90>DLYWBT)a zsjn9l34^reWWwAB0Z+88P|WqD9)GH3(+h$W%9G)N{xDj=TG-w!T zjHwUL+$ukI@dOmmuxsli|tv@qyc)-uB9-?4lm{wq-#I3}hPYZd2CI2-c zj&H!Kds&Bw4ou$1$hU0s=144T*!QA6yH+JPMMnE5x5r+q&!3t=B3vGq;3nT0v|RRZ z4ImUIUFF^&i<}El^`MwEh#0?Wqcw!sHqy+sJtyozjQ`!DAh59TEA@YEDSYszH*{b0 z=f#wm(38qGT43M@tbJqQocC~AK$OALNvezl9);B=#62W-`V$6S?mobq;TX@UYR%4j z`;0KX>SXOgV91{GWRZvem{p0J(IbjJ+;x|}8Zq&Pmiex%sINY`@)03B&z%20A)Y;J zxS5nuDp07~-eMcfyvDqUvV|}fHgFkKg!DPwp_ieJjow}Svv|Rq%F93pkB*k7EW6%~ zF^}(7+*XIuz-FgFGM!+%uOe1K9c=m?c$_7Ji8&5Wy;23HznjmUacBkzC}n zIYbeg3UB`oiDFpsJ&Qh3C@?B1crqSKFF($c=BT~U4dQviVcGhfYc4Efx#9_ zjvQ1c#cUzF+~jPh2Y3wChFY*n$!l)dz<+mgAz3ydN?&MKKzZuoKkrx((^Eg0gKIwN zvuBeyZZsB~;>oFoUU-_-zcnsgEsS{=*Vl`Un{dz zZ6f3wdVbt_oV4ZK*_}rtcrYvwS(tTse)drQ#ed)Q%2J})^Id&dVi$B4dT=!=aLxVs zV1vy35rLn|ZQ$w6%3Bw@ay-dK%YnFc3-jIqj0bCmqpY!G@Kt#9b+D0a>PoAiE(`l* zki37$<|?uMfbh%uLMiO90;h-__jpDLsaG_~z3jVgR|a;7$$qZVcfbc+UfC`1HkBEV z)Fx@#y(Jd)riU~PoHWID@w>OT=;*SpY`iSrziZu#HCnw{=M-tB&K*B=qnbCUr2M@Mf=WYgn)KESjCtvvEx^r!P3T*O+Y zXV46uOkR>abomUGLuE8>C9tl!OHP5_>XLrp$yQ$|oEBQP)?lujzIo4Rj_e{g8gUT* z3xrZpz5yC|gyX9weE4@JQ9&wI?j?JYzYyF+2O5Gwn`76m9F>_&z$Ch^_H!abVUsS?m zk+Jg_|K6H8mG%9E^;gclfB&8s3-hJdyR;;0aoq~{_W>@1zy4I&|KM_s-!+tL*}Bx!5mFl1l-A59a?S+APF0t;q=`Hff73g;>XPfST z3UV6aL?;)2P6Gd8fqTQelaF{@aP#eK$8Ae!B8!J2$xQPb%bUw%uqR7Gj?3S!45!~?id&k6v8bzeZZJFgd`VqT7kXIT)hjZkr%L8& zm@%{D8=~msX{oQXSkZgHZ-gn|9YQ8d!?N*4v&d}SPYiB71(_o=vk|;^f&S$}{S$Tf zci4dqwjUf!v$cNxL*WMao21hBv$0n4zwY)mZ;itVyc?CF@p`UOl{-&zE_@5RQTelY z+S9~$#(B^MUuQ83m96ihGBJ+1Pb%GK#CYM+B*eA z%?xLMS*seD zCEzqNf}#Uag6}DOEkA@IDV)cob(d>~mx||&EC1@u%~pUijHeKEQ^ahp^JPe*i0enR zOAeC;g*C7FF_rpbHr-1_x%6>`QO^@q_StouXTxK-;Ne}NILWc z`Ahu9QUQ5pH#L8l%jRA%9w=C@|GMy#eilz-=Is5HCfZ^s&kMAfvt)F2z+?Zq3qvL! zcZR$wGLQHjR`LxNPUO7E=gY=d%8i_r7*LkQT!$&(G0R4pgkO}VD-a*+2Rq{<53YkJ3Yyfp0LCLRm;=}^p(eu zR%Pcl0LlSiw%u?4)`>^BuLe!-Z{r}xW&Edh5tqvKgpq?^2ma`MJT zMJpUsn8yv`&1lNOeg-Yo^}T^}%|;R{ES|Y2`+-z9L}w&rsmV8mpq~-t^8~VHxAOyE z+Y|zlYc`+wmD@*jQn7%Aclu_k2YFopFi^yJ{KS2Hkj$ZMwZA3M5JjH{I* zD>==)EiWUvd84{b^O#nOq}aA#O$R*gluNG?dYt0b{IXQDbBP?Y=cDAwzv}|Is9(VZ z`BG3LOaDfjIzK#g$KM(0K+T%0UX#38X&n5A-|jB-oJ!#eW)iU-?vv?tTAb%mYFPaf zZL%e%-v*`>X2|PkOo)W%UIJq0RNSWfh#2_OeXZ@F3~}bbcbcT3Hx9YKtL4O|D(IMK z%;ztmL}%Xds$2{?i*QC+OQRgYsb}~FLWbp5SaH& z`akGRU2&^7Q>G5D@&JzV*zH<0WtsWMYGM#G3yjSYfCD=DPo8ePTg&za4Yfg@Yknm` z^04HcTv?7%5@(@=J4?*p2WSeO#qcLR))l8IPmxiup#hw&EP&}nkOa?rVp>Oc5?EdY zI`|xXUl?jsXju)GGw8nekTI$XTPGFb&synl9tL-xSj&J?`dfph1a)uRt*l~(H_dQU zA4ACK)^?+(qQ=sql8TrYVs!~AA1>W@rPdiDc<|pD4!jUWWfZ~yTd4~&2t|kj76iWs zi2vuWs)$GQPtJXhxmx#}CIHg|DznpkkSoVP?ItJ%P?CEvMHfc$$D}kl_(j!&vkqF= z9SM?JO0WbUknf-ZhxX1QWuXgPV%Meql4!w$3V#XnjsiT$w^tK3A8eWNi)qnicj}xj zw0nh`W4YLFrPB_TZp`Bnz^Z>s$9R+WlOg(8jJU48+r7$@S;WF^hB~@ZW3cfZUgYYQ z&2|Nj4FN z1=>ib?I)+zq+EG=L7U zsYRgUcVM(JK%>waSR1OQP})`USWsDu)!;N@=sMlAEi{!47YEk#ru^Q=r2W^%`KDyE zuY{A<>BsSn{V5O+z~xPmvrs*fUet~^)+=Zm%8 z$?UB0W4t28vFppODXJi?y56d7L5OexJgVkw$dd(nd_BGMn|M+-0R9UR3Yl z%;K~p_0m0E`7P^$)2CcZq0>RwPR`v+0}npxHSYkA5;UD-ocf`s9st@(LHq6uJj z&aB*bpnlx(mTXq^aO?a9d>0|ZfG7zUt>@ooj+*&XpunaV=7v{-MaXVN(Agd!lM1^o zJHuES$V~TNTqyUI>tleaNc&__6{dcABmj7mO*y(np;b%eP?=z$acgOxL zd&m}nbpeg%VDDjYgEvNozdRWR`sLE8PO;DHu zEtL*nRNq2Ec!u2eHTl5WArh;YWzC*6LyRSW;efVACxUwnL06z*X29*wmZky;-p>ah zQ{(5HvcZx~YVi9ns#ua0DHA^sS$8s;U>jv%LOy9k-`w>4%FS$FK_#Hc@wA3F9Xtu~ zXoA~__H~qkjwyGcO>560t`E5V3;D=_4bMS-1WS%WG5py=U@0BM1W=tnRYV>)QcccU z(gJj*Kx`?2+CYXZbla0~T8viub5zR8&zA7`w&XU60WBH{fILACm;dh^k92QzU9$g9 zcO8mEPkEs9BOCSn&Z1&qoOv@vzvD~>V-nt$Wy|;JwU1Qo60~wX| zn2;4j5z7h6bKZmlt%9m~k?hpm`RxRkP&q2F zSK(DmudSH+B`U^M)Ed?DR!>4@XF`+5u7067zBc+t8R5d=+thcBj|T!dYM5TVbPV}^ z^J3gMuYHAMp+SzKm0aI9hZ~7@@!C?4Bo?PW?kN>9IW}H6O#52c`Oz@?`cC98!MX%z z?cM^PUghuW(^H;NOB2Sz?|o+O?kyjPwN?=s5%V=@eE*{Fo@(&J)|c-(oB6`MPaA*e zzwVO7b!GUW(kgY!Yv^`u`>u?IJ%oOz4fs)dOQ$MxR8^w6_MRKZyMgVmuUBgqGaBhu zOFY8Wy3E)EkamN+uS)WJUu7IPX;*J@eGJ)@nh5SSVUKrMmC}&=T+-6S=EC7sGO1#W zz!$Zl2l~<+JV7Q8__@iLMZLV_;SwDhxwlP^s7quGt+AG-S{i>R2%x*|{M9}>@_+d@| z!kdH04>vzABn!ck#CQwE!i#E%yQ7?ImkbOhDU&JaR+U-Vvw6at%1Wq>>EHO@r40XC z>M;poPD`OSt^Z6FO+8v+XbbT|CKqFL{NQPc4#zUw*o`7na3oM-WC6R0BYI zN>KSgI9pOQ`x4ADi$z_MMeB9bH9)Qgj7{xpQv_9hK+Pf>Na*mQR}7<{`2|~i%w8MH zAbx;{kqrxFAT|Wy8&)+>EZis+jk#&;V8mvs1%TEOSQUBD?H_cl9|fBJWqts$(`yUJ zWsLDv|LOo2c;cBsH&c{E8@$NMh|>{K=&Lh?gjFAz%MBes_x0)Yz6X34`JBR zLMR)6ouW;Tw{5Z1&n!KN39KryXypxA?tPN)>3EMtAE(1^hVtlBEFiR<_-_673-P5 zB&Z&hXedvSb+F3{`KwYsvM#m#furSi%f-VQNk78YXQv)T>dj`2k*#-?hk{Ww|D+%O zIQZFCn=*%YP4JO#2yDJ_TX8*TsYjJ^qIKZ=B3{TQNN>F|^S+aC zl0I6}yZ+{j47=;%V(zv0S1YA#PHl%_+qM03S^JIrsz6^{R(@k&y8LQZ~uzvZ()ZE?q7s6On-+8G0rkJZ<;qP!l}!lSn-uBM_#e#TnvLZ zQp8{KU>C$hmjZ#nmVSjQWUPkxTO_v@(J>FVMlW}5Id3CY=^qC z-)%BBHR(_?T2Tvw`zcZWBSrco3BgYqSzw)|Fudl`kv&iQu{SZQxEn1`?lU^Ib|1RH zHTeFbmCXA$=z&P+alFe&JrP=YuQ=7Yh$L!otGx;={hF;6^K%isbM^nq@bZdS&%N%t zwRZ3fic7-t?9FYOXtX^3Rt4GV6~(iX9DS*qY7eMkT)2bhQ%4_HfhAO!9HO96mE;OE z-AD^}U>-y^I!amk@46N6Hbyc=gD0nl5_>R;Qgp#2mX&k~$fi!ts!8y7LFe13$WCTQ z(QoE~56bm3WgYD$STSuF+JSR@so9Qfd(-yh*1<7E`bA~SmCgLe%$afV{=Bi4-$0k} z_^h-4(%2QYF|Gk-A>x4==rDJnXfe8z>90X5kq!))4TOH8sBPl@eUhg7Qp1msAlTQY z_MZ>1DcB^f<{_BQ2B8Ll4O2)#y2=KjbF>GLSS&hWICK{G9t>vNh7}hv#4Es`$563U zH#~+5mNQ2nBrHk)S_C!5t3mObhLUw)e(iy?-HkJBhYxq24tS#d5i1ZM005WSyB&{g z`fxXTC?ybAEXbCdYTyOfqUtCxm_P!vms*d1f119XO<8Ox3YGX28T9mWxF2~ze_~%; zCTsBb^e3OYo=rD$!0|FG$Mu)|n21+?J_?Kv!Ao)jR)OAkVv@jp zN^F80vxLEtwUD0iVc6PzWrL5lbmzd8Xn+Z0P1~pT9z{MPs<7c*K%Zd^YP7-J*fK&% z1v?39#>j+GV03>a3++&(1hHXw8Gh6eB_(o%U=OWPYu~jRJl08yWePk5Uyh;$;hd&f zo;r&_cYq%P`^q0vNj>*Sc?F@9yX%^>O7D;{su? zAvp8a)i#5gf{&PM4DC@bo2(I({%LEEV=qjS8|{lq6rY9UGfhBEP#@S7d33>~qpO$x z24GdjaikA6gcyk2F#sL$%IJUnllPMut+XwIKV=6$fnE-{ zfQpyHudILIczjKe6Ovu)iyw#YgcaDGhq5Dz?eZ#n&Z|#j7z9V zqO;Nzc)(_1h3D4GyFrAx^60kie-#=zYLsh3-aw-K zdP;V3uU~>{tdqeFpp7r+#6_ONIwP@)+d3Sy^}!cKw;TbeNNww2gSS z7XPat$NqftVbrgid4u{QqgmRm1s&HkEuLqwzjM7@DJCW?B2rmdIj!+|2YAD#683Ap zCE(Amh0iSkU+(@a9V@z){VkqX!SrT@?%@+&v2h;WRZrIj%6|VnIgA>0Zj9Z4V(d5rf?3WuRi#(fE+?4vAQ#2O3OK^u3a&4^q;&x!B%BLf=RRq##RnK zpfs%$A3X#n07+7;6?PfrDZf8tg1s;dsShW!?;w8%_1ZJ>-M>dqe=xFZJIc6H^K5KO3oH0@sF;$k%2WgauH32tzxqIQPp>N`0?mSdg zssJ-qjl=ZDn|%6-#OwO>l0M*CdNw*BNq_;Y;3yuTZ%JofP3`0+I3(N%zO~Tei z?8*njOxc2GSh^v^q8EDVMudCA%=WA};Qax`B~=$_xQb+>&7kF(0BuWw9F_sK%54u% z;uG}mR+r2cVHx?tU1blyn8XmTo9*jfi~lZ?VjW5fApn*!gvXBjd}&>!K(lDohLj z_F2iO{#7W9b)1gTwj1{f)@`u&((sGE@amoz#WQK>{`YQOE{uwmzs#YoZ z`eP)TKucl(D)9rp8HNn=$T~ZU6DtP(BM#aGYxe!79#2#K6Y7v2OpuZA`g0!qeaDJT zeKch$4)DTg`e_CAj>{7CU1}I4Z-GC>JD^1IL?aq8C=92DAA!&TmaK^d1S#@t>v+WX z2=cUJiza^$Xaa|Q?LQ=eh-or_e9+kr{TOXWUxrgc2o3^6o*=$DODIYOql#q(q%PeVOJVTF|NJofwq7TVC&LOX zM<&pka$=Z0S&Xcv8uRHUOztBMD374NE}&wtGw`|D@3Rg^-816lf5MZQ;kXL$)jO0$ zL3ic>Rl=nQeKcu*hiqgy%Q;0U0^oOo{8AJb5c+?6GXxfY&f>J&s)m`w1>VJ_VIBqH z*1&?0Yo{NEBq8LpD-7SAZhQleO=>)hNaov)Whq&r&xyQQ_$=N|@S}7d`=QvU1ZVwD z1!GUJLf66C>F%pevEi(+WSie>^>4DG&yvqUNxp;Aag5tau2biF6CI$_E`@wROQJ>2 zB`{%_Fw7Ld9g=&2cvEnY{3ug=Fk4m>eTRddaxUqd0_uj@6io?jgB*w%?nT_TB0iJM zI0e78+E^Royf?_@)z=H+2ip?lDICaR@O5Jlrk-a?mjsy&`Mk z$!}YadTz_=%fI*^1Fo6&!^NyPm;%fTFZFnkX(^D80M)sR3EUv?sF*6t{3eJ%(u+zr z0B%qIah?^jM_{F}M_NpMar=&J$ujNtG?DObXM~Dn9^#NV*X#R3io)Gnt2TTPKGcC& z7_M<4Mc7L@KDkH%Ye6;`$(J#bgaV3&Nycl~*WZK}3`d?gKFN#-5Ro!O(`L@ZcE#L; zNuOSy@jUtlY72aB_=rP(7VyGs2x?y@=|AKp&<3{Rk}=K-k4)Va4u=-!HWgTOT&7rA zGkI_Z1@f_X>L2muXJKA84M1;blb=V@n+K&a~S=pL~=ZB|voEFR!7WI)^ zJRcW#hJVZsCG;pR>pm=A_Vbni*Ml}8@x+rE8e6gvgR;?sKJamN=>^(x!Z71DXqS}! z?Rs^(m6b@^JtsY}fyb(q?WQ@oK{D)9@0|?ai7jMeOzFP_A+g8Or;oB2$rU zb#Roj)SZ@YSiCo(cJz330{duJBjE+j9S-VHcQi`01>vrSjSiwLDpUKxzd-_2CepH@ zDa%y<{SOwjfrM0uPd~(n{spWKXUC1@X#&0yjDZ-QZMDFTpI{=RV}O}E*4#T#5HE?e zV}&^|uE=9Qy<()5Wc&R#zr7I>wFN>`fDj6dl%6doA;0&B_|sxHTEsh)sR{nz!}rfe z55J%IRDEaQNTV74jp4xGB4AC|Xs(vb@AB$+Vj5MEvj+rI8UL#GsrxJzA$Axd#jYQc zsv=Z}&O1VjawHl(c*PP_f^s?5XNIQ*#= zR+#QT1}L1WL=Ys<40q#v-U@B^;PNjBQg=QOl*T6k8d6B3Z@~)K2q!M8AX)`_2+@bk z%k@5_NHep-#&P*9a1P=$A@#h=dDS{69hMilT1aAp5UC2{!rOfKN0WiIifHazIZJdi z+6T2q3beV?R?>g_=*B)~*P{6L>M<6qbJwcM1K)kaxUMB=`21plZJNChC zizAyj*)B#m$oZ76zQWMqyyzhM1bQ&XzzzDrKQukMcsc4TR#_ zA^DQnW*Zp?q*Yyu*;YM|7CP`s_DJT_m83tjeEJ&MzrN|&#e7>|y5jXlZan1JZ=c9~ zt7T?gMy7NV6exsmAZQ3tAZLyMC+C+=N2AdA%L;|-fBIG~66iwHflwy`Ekpp^7MzIl ze8|86rNQ4JD1qpa&?7ra0@0l~ww_xvFunRGrI;7lhm3I!;Ol_dUfOsDj$$_J9RV`e z?zOs$Bn4l-Oe8TUv*^*MfULpRkdKhii17~uPMEZ`&GvG%BK?WIU(o5cJW>bi7nMly zCMe-Q(qDMByjnoJ4HCvb$1vU**P4}-Gt`)?DyP(EiK@IC{bFpMbIp5Kef=eSbAIXb z3`MTVzar!h?3z4yA?gdJWN7^79?G8@s|OcVXdo(mVkS^ z$PMUF;40&NJb~`~#dOjf-3D7Cm7MKr)o=WQxl#7jJuy0@xQ{KMf1eJgJTdFU8@qwT z5;U`5yD$Z6Oy0m}$tg;*^WftMuPI9iCywn&P%J5IAH-4Td!PjvG5z);C}oTRTkB(= zZbJkpGt1z<&;Q&QmFO;Cw!qOVPu!` znTGFh`{0|4j%tPhO%>?tzB5Ok(INlFxu=()$WMW|gE5Sr=)?Luf0e%(hd%sr{1j}~ z_4uO!&=P?WC9A0_8>wp3C|tm`eq=hbV2OQaoNUhgTkCsy+s#Y`zsv`|<}BnC>u0wO zCo+_G4~}-sfdKyFy&#k^?FY5nrOO*GMLip%UzXjud8j&I51KhFD99@yyEK+(AiaK0 z0x@4jt$GK{d1-!Kn7sYq+Kn_wOC;z5m83;P;@R+zaifq)cd#7(t+*guspv-J-L}EN z|H9BncGVIXXCf$%9mU)cwOgeI=fwF&H%XD2M+!mgm^gf#Yy4$a>2`kIkD`aEg;R?p7cc@d4uj?Bm9}pEe_MF=w?m?u%$CB5j#%FAU#cfIhgY%Kie% z#GVP_81N*>1$GYJsoyOEDIC`j2`3!(Ol+z+J`9pfN@Gk$Fm!^uD^O5DdqL_gaF_}C z`Z+d+-I-D3?~v@($hY!XvnTfSRPY+KmTns)z9ASFQQwu94=}WfFmBwCnT$-|dR%gX zu7{m9gh^jB0W?ATUY0_FxhXVs?ppldG7JqXMu5o#)EY=VPa@SS@VX)*y!ru$eG{X# z#;IOG`ZLF^&n1}}BK<%ooiC>?Qh4JiVvfaTo{dG6pZld_i>0i)uBL&h-XHO*dfy?V znnx5*d)*ta5e%YIFNunkwd`EHb~<_%kB82{vY zF8DgOS^vORI5|7Qt_f-2Ro0|gTT-y{IOo2{kltYg6*r?n5+mHCU9pGwF$&5N;6)ls zVKx0QCKa9VvK!P03I~`U{7`fJt3xJ&zDWwS=aFl6)@rehXD%P4S@32?UWkW7>M*c6 z04zuVJ~={ssq?#U&+gA4qT7~d5Fn0i2&tKVbna1?l8j6I@|_%Mf~Fo=35TMwfjorM z%{+i3!u_IE2(ZH>WiXfpEL=q*MLWgCCw!%~8`;cXZTheY=pU(Xzedl{Lu zQ?#Yxeda!1ou;FS5IOZnnucV`NcQ6KzRxq^tGS{eX&neu5cHl}zAfARGvIP`SkWIg z)VKuUS3+ka1Y!iT^F`7K=X1vef_or~1B5#ZeDL!}=zJB1I;*gt=8 z?|<_A@$`5@FV44pB($e-dx%qTGNCFDub z5QkZYfY_Cxh9}dKR?y!{M&Hbz09j)eCJLh&z6-Rnn-4~dLQ1DJe(lG3fvR|@c4W!t zr(R{Y_!`PR>a*%{#wF=KFHLT~9gtCnIybOgIOi=h!w5 z4Ej*{PA=%Ni&K5)a3h;WiqMT1~Y!?TKy*}k9nZJOI<@)4P9?P5c$xy<{PxG&l~jIeD}-gK3>O1E>W7-vxX>6)D@D((ON_tq$NRHKd3Y&Z*hqHI zF5j4lK~S{G<08@Ns#>pz;iOe1W`3XiL6;G3rQBQEKN-`_%lq5(b^X5A%`kKPw@h${ zBfhh-&ZfH;Qkd!Sbdb7@^|k&wD3kI4)LlTN>wm6IDLNNZrm<94At=70fh~oDm{6EUFX-Kc6vNOyjj%w3> zYdWHQN?sZGAQJA&`UF=O*)+Z>9c-0b>SoMzU+D*x!%xL)6j`0>(d5nbrbsE!;p*)E z`fQC8WONjK6xXIm&|()#~QB_t9z|8*_S{Sv3^!MDC0 zJpHnBHAv!yErT}Xec+bGe3%znrAT=vL&oFn0X&t^DB5T>+L-13{I!C>>F}e>rR`hS zy_L<^MVT#4aby3v1w5fYUH_|rxa?uoxTL}RU@0VLy|73{nXw`#7pfM* z9rT|Wa-H8pUH8<=q#*f)b)jG0o{`u*cp79)MQLhbH1F2K-40g)u&qIY?{mARs_6`r?82%Oe z{YAtJP}5cFpTwGDlcv+VJ*woG2)^*5=PJy;KKV`T!S?+ge`4jIwwzu0x}R7yv8Exi zF;8O~%;v3=;IVi7>5ZPlMrjhSNpg~XQoMJSQf=!Minfs9p&felZN378fj=IpMaO?O y8wY?v=LPd`M$gph|0|#VUz6t9$2f=o5mAhfEx~W~A$3##<1o-Q(W%wKll~9A%R!0& literal 0 HcmV?d00001 diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/index.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/index.ts new file mode 100644 index 0000000000000..df85a10f7e9de --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ProductCard } from './product_card'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.scss b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.scss new file mode 100644 index 0000000000000..d6b6bd3442590 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.scss @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +.productCard { + margin: $euiSizeS; + + &__imageContainer { + max-height: 115px; + overflow: hidden; + background-color: #0076cc; + + @include euiBreakpoint('s', 'm', 'l', 'xl') { + max-height: none; + } + } + + &__image { + width: 100%; + height: auto; + } + + .euiCard__content { + max-width: 350px; + margin-top: $euiSizeL; + + @include euiBreakpoint('s', 'm', 'l', 'xl') { + margin-top: $euiSizeXL; + } + } + + .euiCard__title { + margin-bottom: $euiSizeM; + font-weight: $euiFontWeightBold; + + @include euiBreakpoint('s', 'm', 'l', 'xl') { + margin-bottom: $euiSizeL; + font-size: $euiSizeL; + } + } + + .euiCard__description { + font-weight: $euiFontWeightMedium; + color: $euiColorMediumShade; + margin-bottom: $euiSize; + } + + .euiCard__footer { + margin-bottom: $euiSizeS; + + @include euiBreakpoint('s', 'm', 'l', 'xl') { + margin-bottom: $euiSizeM; + font-size: $euiSizeL; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx new file mode 100644 index 0000000000000..a76b654ccddd0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiCard } from '@elastic/eui'; +import { EuiButton } from '../../../shared/react_router_helpers'; +import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../../../common/constants'; + +jest.mock('../../../shared/telemetry', () => ({ + sendTelemetry: jest.fn(), +})); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { ProductCard } from './'; + +describe('ProductCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders an App Search card', () => { + const wrapper = shallow(); + const card = wrapper.find(EuiCard).dive().shallow(); + + expect(card.find('h2').text()).toEqual('Elastic App Search'); + expect(card.find('.productCard__image').prop('src')).toEqual('as.jpg'); + + const button = card.find(EuiButton); + expect(button.prop('to')).toEqual('/app/enterprise_search/app_search'); + expect(button.prop('data-test-subj')).toEqual('LaunchAppSearchButton'); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalledWith(expect.objectContaining({ metric: 'app_search' })); + }); + + it('renders a Workplace Search card', () => { + const wrapper = shallow(); + const card = wrapper.find(EuiCard).dive().shallow(); + + expect(card.find('h2').text()).toEqual('Elastic Workplace Search'); + expect(card.find('.productCard__image').prop('src')).toEqual('ws.jpg'); + + const button = card.find(EuiButton); + expect(button.prop('to')).toEqual('/app/enterprise_search/workplace_search'); + expect(button.prop('data-test-subj')).toEqual('LaunchWorkplaceSearchButton'); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ metric: 'workplace_search' }) + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx new file mode 100644 index 0000000000000..334ca126cabb9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import upperFirst from 'lodash/upperFirst'; +import snakeCase from 'lodash/snakeCase'; +import { i18n } from '@kbn/i18n'; +import { EuiCard, EuiTextColor } from '@elastic/eui'; + +import { EuiButton } from '../../../shared/react_router_helpers'; +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +import './product_card.scss'; + +interface IProductCard { + // Expects product plugin constants (@see common/constants.ts) + product: { + ID: string; + NAME: string; + CARD_DESCRIPTION: string; + URL: string; + }; + image: string; +} + +export const ProductCard: React.FC = ({ product, image }) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + return ( + + + + } + paddingSize="l" + description={{product.CARD_DESCRIPTION}} + footer={ + + sendTelemetry({ + http, + product: 'enterprise_search', + action: 'clicked', + metric: snakeCase(product.ID), + }) + } + data-test-subj={`Launch${upperFirst(product.ID)}Button`} + > + {i18n.translate('xpack.enterpriseSearch.overview.productCard.button', { + defaultMessage: `Launch {productName}`, + values: { productName: product.NAME }, + })} + + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.scss b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.scss new file mode 100644 index 0000000000000..d937943352317 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.scss @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +.enterpriseSearchOverview { + padding-top: 78px; + background-image: url('./assets/bg_enterprise_search.png'); + background-repeat: no-repeat; + background-size: 670px; + background-position: center -27px; + + @include euiBreakpoint('m', 'l', 'xl') { + padding-top: 158px; + background-size: 1160px; + background-position: center -48px; + } + + &__header { + text-align: center; + margin: auto; + } + + &__heading { + @include euiBreakpoint('xs', 's') { + font-size: $euiFontSizeXL; + line-height: map-get(map-get($euiTitles, 'm'), 'line-height'); + } + } + + &__subheading { + color: $euiColorMediumShade; + font-size: $euiFontSize; + + @include euiBreakpoint('m', 'l', 'xl') { + font-size: $euiFontSizeL; + margin-bottom: $euiSizeL; + } + } + + // EUI override + .euiTitle + .euiTitle { + margin-top: 0; + + @include euiBreakpoint('m', 'l', 'xl') { + margin-top: $euiSizeS; + } + } + + .enterpriseSearchOverview__card { + flex-basis: 50%; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx new file mode 100644 index 0000000000000..cd2a22a45bbb4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiPage } from '@elastic/eui'; + +import { EnterpriseSearch } from './'; +import { ProductCard } from './components/product_card'; + +describe('EnterpriseSearch', () => { + it('renders the overview page and product cards', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiPage).hasClass('enterpriseSearchOverview')).toBe(true); + expect(wrapper.find(ProductCard)).toHaveLength(2); + }); + + describe('access checks', () => { + it('does not render the App Search card if the user does not have access to AS', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(ProductCard)).toHaveLength(1); + expect(wrapper.find(ProductCard).prop('product').ID).toEqual('workplaceSearch'); + }); + + it('does not render the Workplace Search card if the user does not have access to WS', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(ProductCard)).toHaveLength(1); + expect(wrapper.find(ProductCard).prop('product').ID).toEqual('appSearch'); + }); + + it('does not render any cards if the user does not have access', () => { + const wrapper = shallow(); + + expect(wrapper.find(ProductCard)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx new file mode 100644 index 0000000000000..373f595a6a9ea --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiPage, + EuiPageBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiPageContentBody, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { IInitialAppData } from '../../../common/types'; +import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../common/constants'; + +import { SetEnterpriseSearchChrome as SetPageChrome } from '../shared/kibana_chrome'; +import { SendEnterpriseSearchTelemetry as SendTelemetry } from '../shared/telemetry'; + +import { ProductCard } from './components/product_card'; + +import AppSearchImage from './assets/app_search.png'; +import WorkplaceSearchImage from './assets/workplace_search.png'; +import './index.scss'; + +export const EnterpriseSearch: React.FC = ({ access = {} }) => { + const { hasAppSearchAccess, hasWorkplaceSearchAccess } = access; + + return ( + + + + + + + + +

+ {i18n.translate('xpack.enterpriseSearch.overview.heading', { + defaultMessage: 'Welcome to Elastic Enterprise Search', + })} +

+ + +

+ {i18n.translate('xpack.enterpriseSearch.overview.subheading', { + defaultMessage: 'Select a product to get started', + })} +

+
+ + + + + {hasAppSearchAccess && ( + + + + )} + {hasWorkplaceSearchAccess && ( + + + + )} + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts index 9e86b239432a7..3c8b3a7218862 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.test.ts @@ -37,27 +37,37 @@ describe('useBreadcrumbs', () => { expect(breadcrumb).toEqual([ { text: 'Hello', - href: '/enterprise_search/hello', + href: '/app/enterprise_search/hello', onClick: expect.any(Function), }, { text: 'World', - href: '/enterprise_search/world', + href: '/app/enterprise_search/world', onClick: expect.any(Function), }, ]); }); it('prevents default navigation and uses React Router history on click', () => { - const breadcrumb = useBreadcrumbs([{ text: '', path: '/' }])[0] as any; + const breadcrumb = useBreadcrumbs([{ text: '', path: '/test' }])[0] as any; const event = { preventDefault: jest.fn() }; breadcrumb.onClick(event); - expect(mockKibanaContext.navigateToUrl).toHaveBeenCalled(); + expect(mockKibanaContext.navigateToUrl).toHaveBeenCalledWith('/app/enterprise_search/test'); expect(mockHistory.createHref).toHaveBeenCalled(); expect(event.preventDefault).toHaveBeenCalled(); }); + it('does not call createHref if shouldNotCreateHref is passed', () => { + const breadcrumb = useBreadcrumbs([ + { text: '', path: '/test', shouldNotCreateHref: true }, + ])[0] as any; + breadcrumb.onClick({ preventDefault: () => null }); + + expect(mockKibanaContext.navigateToUrl).toHaveBeenCalledWith('/test'); + expect(mockHistory.createHref).not.toHaveBeenCalled(); + }); + it('does not prevent default browser behavior on new tab/window clicks', () => { const breadcrumb = useBreadcrumbs([{ text: '', path: '/' }])[0] as any; @@ -95,15 +105,17 @@ describe('useEnterpriseSearchBreadcrumbs', () => { expect(useEnterpriseSearchBreadcrumbs(breadcrumbs)).toEqual([ { text: 'Enterprise Search', + href: '/app/enterprise_search/overview', + onClick: expect.any(Function), }, { text: 'Page 1', - href: '/enterprise_search/page1', + href: '/app/enterprise_search/page1', onClick: expect.any(Function), }, { text: 'Page 2', - href: '/enterprise_search/page2', + href: '/app/enterprise_search/page2', onClick: expect.any(Function), }, ]); @@ -113,6 +125,8 @@ describe('useEnterpriseSearchBreadcrumbs', () => { expect(useEnterpriseSearchBreadcrumbs()).toEqual([ { text: 'Enterprise Search', + href: '/app/enterprise_search/overview', + onClick: expect.any(Function), }, ]); }); @@ -122,7 +136,7 @@ describe('useAppSearchBreadcrumbs', () => { beforeEach(() => { jest.clearAllMocks(); mockHistory.createHref.mockImplementation( - ({ pathname }: any) => `/enterprise_search/app_search${pathname}` + ({ pathname }: any) => `/app/enterprise_search/app_search${pathname}` ); }); @@ -141,20 +155,22 @@ describe('useAppSearchBreadcrumbs', () => { expect(useAppSearchBreadcrumbs(breadcrumbs)).toEqual([ { text: 'Enterprise Search', + href: '/app/enterprise_search/overview', + onClick: expect.any(Function), }, { text: 'App Search', - href: '/enterprise_search/app_search/', + href: '/app/enterprise_search/app_search/', onClick: expect.any(Function), }, { text: 'Page 1', - href: '/enterprise_search/app_search/page1', + href: '/app/enterprise_search/app_search/page1', onClick: expect.any(Function), }, { text: 'Page 2', - href: '/enterprise_search/app_search/page2', + href: '/app/enterprise_search/app_search/page2', onClick: expect.any(Function), }, ]); @@ -164,10 +180,12 @@ describe('useAppSearchBreadcrumbs', () => { expect(useAppSearchBreadcrumbs()).toEqual([ { text: 'Enterprise Search', + href: '/app/enterprise_search/overview', + onClick: expect.any(Function), }, { text: 'App Search', - href: '/enterprise_search/app_search/', + href: '/app/enterprise_search/app_search/', onClick: expect.any(Function), }, ]); @@ -178,7 +196,7 @@ describe('useWorkplaceSearchBreadcrumbs', () => { beforeEach(() => { jest.clearAllMocks(); mockHistory.createHref.mockImplementation( - ({ pathname }: any) => `/enterprise_search/workplace_search${pathname}` + ({ pathname }: any) => `/app/enterprise_search/workplace_search${pathname}` ); }); @@ -197,20 +215,22 @@ describe('useWorkplaceSearchBreadcrumbs', () => { expect(useWorkplaceSearchBreadcrumbs(breadcrumbs)).toEqual([ { text: 'Enterprise Search', + href: '/app/enterprise_search/overview', + onClick: expect.any(Function), }, { text: 'Workplace Search', - href: '/enterprise_search/workplace_search/', + href: '/app/enterprise_search/workplace_search/', onClick: expect.any(Function), }, { text: 'Page 1', - href: '/enterprise_search/workplace_search/page1', + href: '/app/enterprise_search/workplace_search/page1', onClick: expect.any(Function), }, { text: 'Page 2', - href: '/enterprise_search/workplace_search/page2', + href: '/app/enterprise_search/workplace_search/page2', onClick: expect.any(Function), }, ]); @@ -220,10 +240,12 @@ describe('useWorkplaceSearchBreadcrumbs', () => { expect(useWorkplaceSearchBreadcrumbs()).toEqual([ { text: 'Enterprise Search', + href: '/app/enterprise_search/overview', + onClick: expect.any(Function), }, { text: 'Workplace Search', - href: '/enterprise_search/workplace_search/', + href: '/app/enterprise_search/workplace_search/', onClick: expect.any(Function), }, ]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts index 6eab936719d01..19714608e73e9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_breadcrumbs.ts @@ -26,6 +26,9 @@ import { letBrowserHandleEvent } from '../react_router_helpers'; interface IBreadcrumb { text: string; path?: string; + // Used to navigate outside of the React Router basename, + // i.e. if we need to go from App Search to Enterprise Search + shouldNotCreateHref?: boolean; } export type TBreadcrumbs = IBreadcrumb[]; @@ -33,11 +36,11 @@ export const useBreadcrumbs = (breadcrumbs: TBreadcrumbs) => { const history = useHistory(); const { navigateToUrl } = useContext(KibanaContext) as IKibanaContext; - return breadcrumbs.map(({ text, path }) => { + return breadcrumbs.map(({ text, path, shouldNotCreateHref }) => { const breadcrumb = { text } as EuiBreadcrumb; if (path) { - const href = history.createHref({ pathname: path }) as string; + const href = shouldNotCreateHref ? path : (history.createHref({ pathname: path }) as string); breadcrumb.href = href; breadcrumb.onClick = (event) => { @@ -56,7 +59,14 @@ export const useBreadcrumbs = (breadcrumbs: TBreadcrumbs) => { */ export const useEnterpriseSearchBreadcrumbs = (breadcrumbs: TBreadcrumbs = []) => - useBreadcrumbs([{ text: ENTERPRISE_SEARCH_PLUGIN.NAME }, ...breadcrumbs]); + useBreadcrumbs([ + { + text: ENTERPRISE_SEARCH_PLUGIN.NAME, + path: ENTERPRISE_SEARCH_PLUGIN.URL, + shouldNotCreateHref: true, + }, + ...breadcrumbs, + ]); export const useAppSearchBreadcrumbs = (breadcrumbs: TBreadcrumbs = []) => useEnterpriseSearchBreadcrumbs([{ text: APP_SEARCH_PLUGIN.NAME, path: '/' }, ...breadcrumbs]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts index 706baefc00cc2..de5f72de79192 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/generate_title.ts @@ -20,7 +20,7 @@ export type TTitle = string[]; /** * Given an array of page titles, return a final formatted document title * @param pages - e.g., ['Curations', 'some Engine', 'App Search'] - * @returns - e.g., 'Curations | some Engine | App Search' + * @returns - e.g., 'Curations - some Engine - App Search' */ export const generateTitle = (pages: TTitle) => pages.join(' - '); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts index 4468d11ba94c9..02013a03c3395 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/index.ts @@ -4,4 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SetAppSearchChrome, SetWorkplaceSearchChrome } from './set_chrome'; +export { + SetEnterpriseSearchChrome, + SetAppSearchChrome, + SetWorkplaceSearchChrome, +} from './set_chrome'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx index bda816c9a5554..61a066bb92216 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.test.tsx @@ -12,18 +12,24 @@ import React from 'react'; import { mockKibanaContext, mountWithKibanaContext } from '../../__mocks__'; jest.mock('./generate_breadcrumbs', () => ({ + useEnterpriseSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs), useAppSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs), useWorkplaceSearchBreadcrumbs: jest.fn(() => (crumbs: any) => crumbs), })); -import { useAppSearchBreadcrumbs, useWorkplaceSearchBreadcrumbs } from './generate_breadcrumbs'; +import { + useEnterpriseSearchBreadcrumbs, + useAppSearchBreadcrumbs, + useWorkplaceSearchBreadcrumbs, +} from './generate_breadcrumbs'; jest.mock('./generate_title', () => ({ + enterpriseSearchTitle: jest.fn((title: any) => title), appSearchTitle: jest.fn((title: any) => title), workplaceSearchTitle: jest.fn((title: any) => title), })); -import { appSearchTitle, workplaceSearchTitle } from './generate_title'; +import { enterpriseSearchTitle, appSearchTitle, workplaceSearchTitle } from './generate_title'; -import { SetAppSearchChrome, SetWorkplaceSearchChrome } from './'; +import { SetEnterpriseSearchChrome, SetAppSearchChrome, SetWorkplaceSearchChrome } from './'; describe('Set Kibana Chrome helpers', () => { beforeEach(() => { @@ -35,6 +41,27 @@ describe('Set Kibana Chrome helpers', () => { expect(mockKibanaContext.setDocTitle).toHaveBeenCalled(); }); + describe('SetEnterpriseSearchChrome', () => { + it('sets breadcrumbs and document title', () => { + mountWithKibanaContext(); + + expect(enterpriseSearchTitle).toHaveBeenCalledWith(['Hello World']); + expect(useEnterpriseSearchBreadcrumbs).toHaveBeenCalledWith([ + { + text: 'Hello World', + path: '/current-path', + }, + ]); + }); + + it('sets empty breadcrumbs and document title when isRoot is true', () => { + mountWithKibanaContext(); + + expect(enterpriseSearchTitle).toHaveBeenCalledWith([]); + expect(useEnterpriseSearchBreadcrumbs).toHaveBeenCalledWith([]); + }); + }); + describe('SetAppSearchChrome', () => { it('sets breadcrumbs and document title', () => { mountWithKibanaContext(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx index 43db93c1583d1..5e8d972e1a135 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_chrome/set_chrome.tsx @@ -10,11 +10,17 @@ import { EuiBreadcrumb } from '@elastic/eui'; import { KibanaContext, IKibanaContext } from '../../index'; import { + useEnterpriseSearchBreadcrumbs, useAppSearchBreadcrumbs, useWorkplaceSearchBreadcrumbs, TBreadcrumbs, } from './generate_breadcrumbs'; -import { appSearchTitle, workplaceSearchTitle, TTitle } from './generate_title'; +import { + enterpriseSearchTitle, + appSearchTitle, + workplaceSearchTitle, + TTitle, +} from './generate_title'; /** * Helpers for setting Kibana chrome (breadcrumbs, doc titles) on React view mount @@ -33,6 +39,24 @@ interface IRootBreadcrumbsProps { } type TBreadcrumbsProps = IBreadcrumbsProps | IRootBreadcrumbsProps; +export const SetEnterpriseSearchChrome: React.FC = ({ text, isRoot }) => { + const history = useHistory(); + const { setBreadcrumbs, setDocTitle } = useContext(KibanaContext) as IKibanaContext; + + const title = isRoot ? [] : [text]; + const docTitle = enterpriseSearchTitle(title as TTitle | []); + + const crumb = isRoot ? [] : [{ text, path: history.location.pathname }]; + const breadcrumbs = useEnterpriseSearchBreadcrumbs(crumb as TBreadcrumbs | []); + + useEffect(() => { + setBreadcrumbs(breadcrumbs); + setDocTitle(docTitle); + }, []); + + return null; +}; + export const SetAppSearchChrome: React.FC = ({ text, isRoot }) => { const history = useHistory(); const { setBreadcrumbs, setDocTitle } = useContext(KibanaContext) as IKibanaContext; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx index 063118f94cd19..0c7bac99085dd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx @@ -45,10 +45,18 @@ describe('EUI & React Router Component Helpers', () => { const link = wrapper.find(EuiLink); expect(link.prop('onClick')).toBeInstanceOf(Function); - expect(link.prop('href')).toEqual('/enterprise_search/foo/bar'); + expect(link.prop('href')).toEqual('/app/enterprise_search/foo/bar'); expect(mockHistory.createHref).toHaveBeenCalled(); }); + it('renders with the correct non-basenamed href when shouldNotCreateHref is passed', () => { + const wrapper = mount(); + const link = wrapper.find(EuiLink); + + expect(link.prop('href')).toEqual('/foo/bar'); + expect(mockHistory.createHref).not.toHaveBeenCalled(); + }); + describe('onClick', () => { it('prevents default navigation and uses React Router history', () => { const wrapper = mount(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx index 7221a61d0997b..e3b46632ddf9e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.tsx @@ -21,14 +21,22 @@ import { letBrowserHandleEvent } from './link_events'; interface IEuiReactRouterProps { to: string; onClick?(): void; + // Used to navigate outside of the React Router plugin basename but still within Kibana, + // e.g. if we need to go from Enterprise Search to App Search + shouldNotCreateHref?: boolean; } -export const EuiReactRouterHelper: React.FC = ({ to, onClick, children }) => { +export const EuiReactRouterHelper: React.FC = ({ + to, + onClick, + shouldNotCreateHref, + children, +}) => { const history = useHistory(); const { navigateToUrl } = useContext(KibanaContext) as IKibanaContext; // Generate the correct link href (with basename etc. accounted for) - const href = history.createHref({ pathname: to }); + const href = shouldNotCreateHref ? to : history.createHref({ pathname: to }); const reactRouterLinkClick = (event: React.MouseEvent) => { if (onClick) onClick(); // Run any passed click events (e.g. telemetry) @@ -51,9 +59,10 @@ type TEuiReactRouterButtonProps = EuiButtonProps & IEuiReactRouterProps; export const EuiReactRouterLink: React.FC = ({ to, onClick, + shouldNotCreateHref, ...rest }) => ( - + ); @@ -61,9 +70,10 @@ export const EuiReactRouterLink: React.FC = ({ export const EuiReactRouterButton: React.FC = ({ to, onClick, + shouldNotCreateHref, ...rest }) => ( - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts index eadf7fa805590..a8b9636c3ff3e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts @@ -5,5 +5,8 @@ */ export { sendTelemetry } from './send_telemetry'; -export { SendAppSearchTelemetry } from './send_telemetry'; -export { SendWorkplaceSearchTelemetry } from './send_telemetry'; +export { + SendEnterpriseSearchTelemetry, + SendAppSearchTelemetry, + SendWorkplaceSearchTelemetry, +} from './send_telemetry'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx index 3c873dbc25e37..8f7cf090e2d57 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -10,7 +10,12 @@ import { httpServiceMock } from 'src/core/public/mocks'; import { JSON_HEADER as headers } from '../../../../common/constants'; import { mountWithKibanaContext } from '../../__mocks__'; -import { sendTelemetry, SendAppSearchTelemetry, SendWorkplaceSearchTelemetry } from './'; +import { + sendTelemetry, + SendEnterpriseSearchTelemetry, + SendAppSearchTelemetry, + SendWorkplaceSearchTelemetry, +} from './'; describe('Shared Telemetry Helpers', () => { const httpMock = httpServiceMock.createSetupContract(); @@ -44,6 +49,17 @@ describe('Shared Telemetry Helpers', () => { }); describe('React component helpers', () => { + it('SendEnterpriseSearchTelemetry component', () => { + mountWithKibanaContext(, { + http: httpMock, + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers, + body: '{"product":"enterprise_search","action":"viewed","metric":"page"}', + }); + }); + it('SendAppSearchTelemetry component', () => { mountWithKibanaContext(, { http: httpMock, @@ -56,13 +72,13 @@ describe('Shared Telemetry Helpers', () => { }); it('SendWorkplaceSearchTelemetry component', () => { - mountWithKibanaContext(, { + mountWithKibanaContext(, { http: httpMock, }); expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { headers, - body: '{"product":"workplace_search","action":"viewed","metric":"page"}', + body: '{"product":"workplace_search","action":"error","metric":"not_found"}', }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx index 715d61b31512c..4df1428221de6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -35,9 +35,21 @@ export const sendTelemetry = async ({ http, product, action, metric }: ISendTele /** * React component helpers - useful for on-page-load/views - * TODO: SendEnterpriseSearchTelemetry */ +export const SendEnterpriseSearchTelemetry: React.FC = ({ + action, + metric, +}) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + useEffect(() => { + sendTelemetry({ http, action, metric, product: 'enterprise_search' }); + }, [action, metric, http]); + + return null; +}; + export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => { const { http } = useContext(KibanaContext) as IKibanaContext; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 83598a0dc971d..b735db7c49520 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -12,7 +12,6 @@ import { AppMountParameters, HttpSetup, } from 'src/core/public'; -import { i18n } from '@kbn/i18n'; import { FeatureCatalogueCategory, HomePublicPluginSetup, @@ -52,6 +51,25 @@ export class EnterpriseSearchPlugin implements Plugin { } public setup(core: CoreSetup, plugins: PluginsSetup) { + core.application.register({ + id: ENTERPRISE_SEARCH_PLUGIN.ID, + title: ENTERPRISE_SEARCH_PLUGIN.NAV_TITLE, + appRoute: ENTERPRISE_SEARCH_PLUGIN.URL, + category: DEFAULT_APP_CATEGORIES.enterpriseSearch, + mount: async (params: AppMountParameters) => { + const [coreStart] = await core.getStartServices(); + const { chrome } = coreStart; + chrome.docTitle.change(ENTERPRISE_SEARCH_PLUGIN.NAME); + + await this.getInitialData(coreStart.http); + + const { renderApp } = await import('./applications'); + const { EnterpriseSearch } = await import('./applications/enterprise_search'); + + return renderApp(EnterpriseSearch, params, coreStart, plugins, this.config, this.data); + }, + }); + core.application.register({ id: APP_SEARCH_PLUGIN.ID, title: APP_SEARCH_PLUGIN.NAME, @@ -94,22 +112,10 @@ export class EnterpriseSearchPlugin implements Plugin { plugins.home.featureCatalogue.registerSolution({ id: ENTERPRISE_SEARCH_PLUGIN.ID, title: ENTERPRISE_SEARCH_PLUGIN.NAME, - subtitle: i18n.translate('xpack.enterpriseSearch.featureCatalogue.subtitle', { - defaultMessage: 'Search everything', - }), + subtitle: ENTERPRISE_SEARCH_PLUGIN.SUBTITLE, icon: 'logoEnterpriseSearch', - descriptions: [ - i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription1', { - defaultMessage: 'Build a powerful search experience.', - }), - i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription2', { - defaultMessage: 'Connect your users to relevant data.', - }), - i18n.translate('xpack.enterpriseSearch.featureCatalogueDescription3', { - defaultMessage: 'Unify your team content.', - }), - ], - path: APP_SEARCH_PLUGIN.URL, // TODO: Change this to enterprise search overview page once available + descriptions: ENTERPRISE_SEARCH_PLUGIN.DESCRIPTIONS, + path: ENTERPRISE_SEARCH_PLUGIN.URL, }); plugins.home.featureCatalogue.register({ diff --git a/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.test.ts new file mode 100644 index 0000000000000..c3e2aff6551c9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockLogger } from '../../__mocks__'; + +import { registerTelemetryUsageCollector } from './telemetry'; + +describe('Enterprise Search Telemetry Usage Collector', () => { + const makeUsageCollectorStub = jest.fn(); + const registerStub = jest.fn(); + const usageCollectionMock = { + makeUsageCollector: makeUsageCollectorStub, + registerCollector: registerStub, + } as any; + + const savedObjectsRepoStub = { + get: () => ({ + attributes: { + 'ui_viewed.overview': 10, + 'ui_clicked.app_search': 2, + 'ui_clicked.workplace_search': 3, + }, + }), + incrementCounter: jest.fn(), + }; + const savedObjectsMock = { + createInternalRepository: jest.fn(() => savedObjectsRepoStub), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('registerTelemetryUsageCollector', () => { + it('should make and register the usage collector', () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + + expect(registerStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('enterprise_search'); + expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true); + }); + }); + + describe('fetchTelemetryMetrics', () => { + it('should return existing saved objects data', async () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + overview: 10, + }, + ui_clicked: { + app_search: 2, + workplace_search: 3, + }, + }); + }); + + it('should return a default telemetry object if no saved data exists', async () => { + const emptySavedObjectsMock = { + createInternalRepository: () => ({ + get: () => ({ attributes: null }), + }), + } as any; + + registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + overview: 0, + }, + ui_clicked: { + app_search: 0, + workplace_search: 0, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.ts new file mode 100644 index 0000000000000..a124a185b9a34 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/enterprise_search/telemetry.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { SavedObjectsServiceStart, Logger } from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + +import { getSavedObjectAttributesFromRepo } from '../lib/telemetry'; + +interface ITelemetry { + ui_viewed: { + overview: number; + }; + ui_clicked: { + app_search: number; + workplace_search: number; + }; +} + +export const ES_TELEMETRY_NAME = 'enterprise_search_telemetry'; + +/** + * Register the telemetry collector + */ + +export const registerTelemetryUsageCollector = ( + usageCollection: UsageCollectionSetup, + savedObjects: SavedObjectsServiceStart, + log: Logger +) => { + const telemetryUsageCollector = usageCollection.makeUsageCollector({ + type: 'enterprise_search', + fetch: async () => fetchTelemetryMetrics(savedObjects, log), + isReady: () => true, + schema: { + ui_viewed: { + overview: { type: 'long' }, + }, + ui_clicked: { + app_search: { type: 'long' }, + workplace_search: { type: 'long' }, + }, + }, + }); + usageCollection.registerCollector(telemetryUsageCollector); +}; + +/** + * Fetch the aggregated telemetry metrics from our saved objects + */ + +const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => { + const savedObjectsRepository = savedObjects.createInternalRepository(); + const savedObjectAttributes = await getSavedObjectAttributesFromRepo( + ES_TELEMETRY_NAME, + savedObjectsRepository, + log + ); + + const defaultTelemetrySavedObject: ITelemetry = { + ui_viewed: { + overview: 0, + }, + ui_clicked: { + app_search: 0, + workplace_search: 0, + }, + }; + + // If we don't have an existing/saved telemetry object, return the default + if (!savedObjectAttributes) { + return defaultTelemetrySavedObject; + } + + return { + ui_viewed: { + overview: get(savedObjectAttributes, 'ui_viewed.overview', 0), + }, + ui_clicked: { + app_search: get(savedObjectAttributes, 'ui_clicked.app_search', 0), + workplace_search: get(savedObjectAttributes, 'ui_clicked.workplace_search', 0), + }, + } as ITelemetry; +}; diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts index aae162c23ccb4..6cf0be9fd1f31 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts @@ -15,7 +15,7 @@ import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; import { getSavedObjectAttributesFromRepo, incrementUICounter } from './telemetry'; -describe('App Search Telemetry Usage Collector', () => { +describe('Telemetry helpers', () => { beforeEach(() => { jest.clearAllMocks(); }); diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 617210a544262..729a03d24065e 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -31,8 +31,10 @@ import { IEnterpriseSearchRequestHandler, } from './lib/enterprise_search_request_handler'; -import { registerConfigDataRoute } from './routes/enterprise_search/config_data'; +import { enterpriseSearchTelemetryType } from './saved_objects/enterprise_search/telemetry'; +import { registerTelemetryUsageCollector as registerESTelemetryUsageCollector } from './collectors/enterprise_search/telemetry'; import { registerTelemetryRoute } from './routes/enterprise_search/telemetry'; +import { registerConfigDataRoute } from './routes/enterprise_search/config_data'; import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry'; @@ -81,8 +83,12 @@ export class EnterpriseSearchPlugin implements Plugin { name: ENTERPRISE_SEARCH_PLUGIN.NAME, order: 0, icon: 'logoEnterpriseSearch', - navLinkId: APP_SEARCH_PLUGIN.ID, // TODO - remove this once functional tests no longer rely on navLinkId - app: ['kibana', APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID], + app: [ + 'kibana', + ENTERPRISE_SEARCH_PLUGIN.ID, + APP_SEARCH_PLUGIN.ID, + WORKPLACE_SEARCH_PLUGIN.ID, + ], catalogue: [ENTERPRISE_SEARCH_PLUGIN.ID, APP_SEARCH_PLUGIN.ID, WORKPLACE_SEARCH_PLUGIN.ID], privileges: null, }); @@ -94,14 +100,16 @@ export class EnterpriseSearchPlugin implements Plugin { const dependencies = { config, security, request, log }; const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies); + const showEnterpriseSearchOverview = hasAppSearchAccess || hasWorkplaceSearchAccess; return { navLinks: { + enterpriseSearch: showEnterpriseSearchOverview, appSearch: hasAppSearchAccess, workplaceSearch: hasWorkplaceSearchAccess, }, catalogue: { - enterpriseSearch: hasAppSearchAccess || hasWorkplaceSearchAccess, + enterpriseSearch: showEnterpriseSearchOverview, appSearch: hasAppSearchAccess, workplaceSearch: hasWorkplaceSearchAccess, }, @@ -123,6 +131,7 @@ export class EnterpriseSearchPlugin implements Plugin { /** * Bootstrap the routes, saved objects, and collector for telemetry */ + savedObjects.registerType(enterpriseSearchTelemetryType); savedObjects.registerType(appSearchTelemetryType); savedObjects.registerType(workplaceSearchTelemetryType); let savedObjectsStarted: SavedObjectsServiceStart; @@ -131,6 +140,7 @@ export class EnterpriseSearchPlugin implements Plugin { savedObjectsStarted = coreStart.savedObjects; if (usageCollection) { + registerESTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); registerASTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); registerWSTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); } diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts index 7ed1d7b17753c..bfc07c8b64ef5 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts @@ -9,12 +9,13 @@ import { schema } from '@kbn/config-schema'; import { IRouteDependencies } from '../../plugin'; import { incrementUICounter } from '../../collectors/lib/telemetry'; +import { ES_TELEMETRY_NAME } from '../../collectors/enterprise_search/telemetry'; import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry'; import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry'; const productToTelemetryMap = { + enterprise_search: ES_TELEMETRY_NAME, app_search: AS_TELEMETRY_NAME, workplace_search: WS_TELEMETRY_NAME, - enterprise_search: 'TODO', }; export function registerTelemetryRoute({ diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/enterprise_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/enterprise_search/telemetry.ts new file mode 100644 index 0000000000000..54044e67939da --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/saved_objects/enterprise_search/telemetry.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* istanbul ignore file */ + +import { SavedObjectsType } from 'src/core/server'; +import { ES_TELEMETRY_NAME } from '../../collectors/enterprise_search/telemetry'; + +export const enterpriseSearchTelemetryType: SavedObjectsType = { + name: ES_TELEMETRY_NAME, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, +}; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 2435d8a9aaf04..a7330d3ebd552 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -51,6 +51,27 @@ } } }, + "enterprise_search": { + "properties": { + "ui_viewed": { + "properties": { + "overview": { + "type": "long" + } + } + }, + "ui_clicked": { + "properties": { + "app_search": { + "type": "long" + }, + "workplace_search": { + "type": "long" + } + } + } + } + }, "workplace_search": { "properties": { "ui_viewed": { diff --git a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts index d7a0dfa1cf80a..091bbccd6f87a 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts @@ -49,7 +49,13 @@ export default function navLinksTests({ getService }: FtrProviderContext) { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( - navLinksBuilder.except('ml', 'monitoring', 'appSearch', 'workplaceSearch') + navLinksBuilder.except( + 'ml', + 'monitoring', + 'enterpriseSearch', + 'appSearch', + 'workplaceSearch' + ) ); break; case 'foo_all': From 9bc603e7d903a400d1dd81eb368ae8466e5647f3 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Wed, 9 Sep 2020 14:01:29 -0600 Subject: [PATCH 16/25] Move metrics to setup and add cgroup metrics (#76730) --- .../core/server/kibana-plugin-core-server.md | 2 +- ....metricsservicesetup.collectioninterval.md | 13 ++ ...rver.metricsservicesetup.getopsmetrics_.md | 24 +++ ...-plugin-core-server.metricsservicesetup.md | 10 + ...gin-core-server.opsmetrics.collected_at.md | 13 ++ .../kibana-plugin-core-server.opsmetrics.md | 1 + ...ana-plugin-core-server.opsosmetrics.cpu.md | 22 ++ ...plugin-core-server.opsosmetrics.cpuacct.md | 16 ++ .../kibana-plugin-core-server.opsosmetrics.md | 2 + docs/setup/settings.asciidoc | 16 +- .../core_app/status/lib/load_status.test.ts | 1 + .../config/deprecation/core_deprecations.ts | 4 +- src/core/server/legacy/legacy_service.ts | 1 + .../server/metrics/collectors/cgroup.test.ts | 115 +++++++++++ src/core/server/metrics/collectors/cgroup.ts | 194 ++++++++++++++++++ .../metrics/collectors/collector.mock.ts | 33 +++ src/core/server/metrics/collectors/index.ts | 2 +- .../metrics/collectors/os.test.mocks.ts | 25 +++ src/core/server/metrics/collectors/os.test.ts | 8 + src/core/server/metrics/collectors/os.ts | 32 ++- src/core/server/metrics/collectors/types.ts | 27 +++ .../server/metrics/metrics_service.mock.ts | 26 ++- src/core/server/metrics/metrics_service.ts | 28 ++- src/core/server/metrics/ops_config.ts | 4 + .../metrics/ops_metrics_collector.test.ts | 3 +- .../server/metrics/ops_metrics_collector.ts | 6 +- src/core/server/metrics/types.ts | 15 +- src/core/server/plugins/plugin_context.ts | 1 + src/core/server/server.api.md | 21 +- .../server/collectors/ops_stats/index.test.ts | 1 + .../ops_stats/ops_stats_collector.ts | 8 +- 31 files changed, 629 insertions(+), 45 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.collectioninterval.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.getopsmetrics_.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.opsmetrics.collected_at.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.opsosmetrics.cpu.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.opsosmetrics.cpuacct.md create mode 100644 src/core/server/metrics/collectors/cgroup.test.ts create mode 100644 src/core/server/metrics/collectors/cgroup.ts create mode 100644 src/core/server/metrics/collectors/collector.mock.ts create mode 100644 src/core/server/metrics/collectors/os.test.mocks.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 89330d2a86f76..dfffdffb08a08 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -123,7 +123,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [LoggerFactory](./kibana-plugin-core-server.loggerfactory.md) | The single purpose of LoggerFactory interface is to define a way to retrieve a context-based logger instance. | | [LoggingServiceSetup](./kibana-plugin-core-server.loggingservicesetup.md) | Provides APIs to plugins for customizing the plugin's logger. | | [LogMeta](./kibana-plugin-core-server.logmeta.md) | Contextual metadata | -| [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | | +| [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | APIs to retrieves metrics gathered and exposed by the core platform. | | [NodesVersionCompatibility](./kibana-plugin-core-server.nodesversioncompatibility.md) | | | [OnPostAuthToolkit](./kibana-plugin-core-server.onpostauthtoolkit.md) | A tool set defining an outcome of OnPostAuth interceptor for incoming request. | | [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | diff --git a/docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.collectioninterval.md b/docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.collectioninterval.md new file mode 100644 index 0000000000000..6f05526b66c83 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.collectioninterval.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) > [collectionInterval](./kibana-plugin-core-server.metricsservicesetup.collectioninterval.md) + +## MetricsServiceSetup.collectionInterval property + +Interval metrics are collected in milliseconds + +Signature: + +```typescript +readonly collectionInterval: number; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.getopsmetrics_.md b/docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.getopsmetrics_.md new file mode 100644 index 0000000000000..61107fbf20ad9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.getopsmetrics_.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) > [getOpsMetrics$](./kibana-plugin-core-server.metricsservicesetup.getopsmetrics_.md) + +## MetricsServiceSetup.getOpsMetrics$ property + +Retrieve an observable emitting the [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) gathered. The observable will emit an initial value during core's `start` phase, and a new value every fixed interval of time, based on the `opts.interval` configuration property. + +Signature: + +```typescript +getOpsMetrics$: () => Observable; +``` + +## Example + + +```ts +core.metrics.getOpsMetrics$().subscribe(metrics => { + // do something with the metrics +}) + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.md index 0bec919797b6f..5fcb1417dea0e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.metricsservicesetup.md @@ -4,8 +4,18 @@ ## MetricsServiceSetup interface +APIs to retrieves metrics gathered and exposed by the core platform. + Signature: ```typescript export interface MetricsServiceSetup ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [collectionInterval](./kibana-plugin-core-server.metricsservicesetup.collectioninterval.md) | number | Interval metrics are collected in milliseconds | +| [getOpsMetrics$](./kibana-plugin-core-server.metricsservicesetup.getopsmetrics_.md) | () => Observable<OpsMetrics> | Retrieve an observable emitting the [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) gathered. The observable will emit an initial value during core's start phase, and a new value every fixed interval of time, based on the opts.interval configuration property. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.opsmetrics.collected_at.md b/docs/development/core/server/kibana-plugin-core-server.opsmetrics.collected_at.md new file mode 100644 index 0000000000000..25125569b7b38 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.opsmetrics.collected_at.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) > [collected\_at](./kibana-plugin-core-server.opsmetrics.collected_at.md) + +## OpsMetrics.collected\_at property + +Time metrics were recorded at. + +Signature: + +```typescript +collected_at: Date; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.opsmetrics.md b/docs/development/core/server/kibana-plugin-core-server.opsmetrics.md index d2d4782385c06..9803c0fbd53cc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.opsmetrics.md +++ b/docs/development/core/server/kibana-plugin-core-server.opsmetrics.md @@ -16,6 +16,7 @@ export interface OpsMetrics | Property | Type | Description | | --- | --- | --- | +| [collected\_at](./kibana-plugin-core-server.opsmetrics.collected_at.md) | Date | Time metrics were recorded at. | | [concurrent\_connections](./kibana-plugin-core-server.opsmetrics.concurrent_connections.md) | OpsServerMetrics['concurrent_connections'] | number of current concurrent connections to the server | | [os](./kibana-plugin-core-server.opsmetrics.os.md) | OpsOsMetrics | OS related metrics | | [process](./kibana-plugin-core-server.opsmetrics.process.md) | OpsProcessMetrics | Process related metrics | diff --git a/docs/development/core/server/kibana-plugin-core-server.opsosmetrics.cpu.md b/docs/development/core/server/kibana-plugin-core-server.opsosmetrics.cpu.md new file mode 100644 index 0000000000000..095c45266a251 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.opsosmetrics.cpu.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OpsOsMetrics](./kibana-plugin-core-server.opsosmetrics.md) > [cpu](./kibana-plugin-core-server.opsosmetrics.cpu.md) + +## OpsOsMetrics.cpu property + +cpu cgroup metrics, undefined when not running in a cgroup + +Signature: + +```typescript +cpu?: { + control_group: string; + cfs_period_micros: number; + cfs_quota_micros: number; + stat: { + number_of_elapsed_periods: number; + number_of_times_throttled: number; + time_throttled_nanos: number; + }; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.opsosmetrics.cpuacct.md b/docs/development/core/server/kibana-plugin-core-server.opsosmetrics.cpuacct.md new file mode 100644 index 0000000000000..140646a0d1a35 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.opsosmetrics.cpuacct.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OpsOsMetrics](./kibana-plugin-core-server.opsosmetrics.md) > [cpuacct](./kibana-plugin-core-server.opsosmetrics.cpuacct.md) + +## OpsOsMetrics.cpuacct property + +cpu accounting metrics, undefined when not running in a cgroup + +Signature: + +```typescript +cpuacct?: { + control_group: string; + usage_nanos: number; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.opsosmetrics.md b/docs/development/core/server/kibana-plugin-core-server.opsosmetrics.md index 5fedb76a9c8d7..8938608531139 100644 --- a/docs/development/core/server/kibana-plugin-core-server.opsosmetrics.md +++ b/docs/development/core/server/kibana-plugin-core-server.opsosmetrics.md @@ -16,6 +16,8 @@ export interface OpsOsMetrics | Property | Type | Description | | --- | --- | --- | +| [cpu](./kibana-plugin-core-server.opsosmetrics.cpu.md) | {
control_group: string;
cfs_period_micros: number;
cfs_quota_micros: number;
stat: {
number_of_elapsed_periods: number;
number_of_times_throttled: number;
time_throttled_nanos: number;
};
} | cpu cgroup metrics, undefined when not running in a cgroup | +| [cpuacct](./kibana-plugin-core-server.opsosmetrics.cpuacct.md) | {
control_group: string;
usage_nanos: number;
} | cpu accounting metrics, undefined when not running in a cgroup | | [distro](./kibana-plugin-core-server.opsosmetrics.distro.md) | string | The os distrib. Only present for linux platforms | | [distroRelease](./kibana-plugin-core-server.opsosmetrics.distrorelease.md) | string | The os distrib release, prefixed by the os distrib. Only present for linux platforms | | [load](./kibana-plugin-core-server.opsosmetrics.load.md) | {
'1m': number;
'5m': number;
'15m': number;
} | cpu load metrics | diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 4a931aabd3646..f03022e9e9f00 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -20,12 +20,12 @@ which may cause a delay before pages start being served. Set to `false` to disable Console. *Default: `true`* | `cpu.cgroup.path.override:` - | Override for cgroup cpu path when mounted in a -manner that is inconsistent with `/proc/self/cgroup`. + | *deprecated* This setting has been renamed to `ops.cGroupOverrides.cpuPath` +and the old name will no longer be supported as of 8.0. | `cpuacct.cgroup.path.override:` - | Override for cgroup cpuacct path when mounted -in a manner that is inconsistent with `/proc/self/cgroup`. + | *deprecated* This setting has been renamed to `ops.cGroupOverrides.cpuAcctPath` +and the old name will no longer be supported as of 8.0. | `csp.rules:` | A https://w3c.github.io/webappsec-csp/[content-security-policy] template @@ -438,6 +438,14 @@ not saved in {es}. *Default: `data`* | Set the interval in milliseconds to sample system and process performance metrics. The minimum value is 100. *Default: `5000`* +| `ops.cGroupOverrides.cpuPath:` + | Override for cgroup cpu path when mounted in a +manner that is inconsistent with `/proc/self/cgroup`. + +| `ops.cGroupOverrides.cpuAcctPath:` + | Override for cgroup cpuacct path when mounted +in a manner that is inconsistent with `/proc/self/cgroup`. + | `server.basePath:` | Enables you to specify a path to mount {kib} at if you are running behind a proxy. Use the `server.rewriteBasePath` setting to tell {kib} diff --git a/src/core/public/core_app/status/lib/load_status.test.ts b/src/core/public/core_app/status/lib/load_status.test.ts index 3a444a4448467..5a9f982e106a7 100644 --- a/src/core/public/core_app/status/lib/load_status.test.ts +++ b/src/core/public/core_app/status/lib/load_status.test.ts @@ -57,6 +57,7 @@ const mockedResponse: StatusResponse = { ], }, metrics: { + collected_at: new Date('2020-01-01 01:00:00'), collection_interval_in_millis: 1000, os: { platform: 'darwin' as const, diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index e4e881ab24372..2b8b8e383da24 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -113,7 +113,7 @@ const mapManifestServiceUrlDeprecation: ConfigDeprecation = (settings, fromPath, return settings; }; -export const coreDeprecationProvider: ConfigDeprecationProvider = ({ unusedFromRoot }) => [ +export const coreDeprecationProvider: ConfigDeprecationProvider = ({ rename, unusedFromRoot }) => [ unusedFromRoot('savedObjects.indexCheckTimeout'), unusedFromRoot('server.xsrf.token'), unusedFromRoot('maps.manifestServiceUrl'), @@ -136,6 +136,8 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({ unusedFromR unusedFromRoot('optimize.workers'), unusedFromRoot('optimize.profile'), unusedFromRoot('optimize.validateSyntaxOfNodeModules'), + rename('cpu.cgroup.path.override', 'ops.cGroupOverrides.cpuPath'), + rename('cpuacct.cgroup.path.override', 'ops.cGroupOverrides.cpuAcctPath'), configPathDeprecation, dataPathDeprecation, rewriteBasePathDeprecation, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index b95644590b4e9..ba3eb28f90c5c 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -264,6 +264,7 @@ export class LegacyService implements CoreService { getTypeRegistry: startDeps.core.savedObjects.getTypeRegistry, }, metrics: { + collectionInterval: startDeps.core.metrics.collectionInterval, getOpsMetrics$: startDeps.core.metrics.getOpsMetrics$, }, uiSettings: { asScopedToClient: startDeps.core.uiSettings.asScopedToClient }, diff --git a/src/core/server/metrics/collectors/cgroup.test.ts b/src/core/server/metrics/collectors/cgroup.test.ts new file mode 100644 index 0000000000000..39f917b9f0ba1 --- /dev/null +++ b/src/core/server/metrics/collectors/cgroup.test.ts @@ -0,0 +1,115 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import mockFs from 'mock-fs'; +import { OsCgroupMetricsCollector } from './cgroup'; + +describe('OsCgroupMetricsCollector', () => { + afterEach(() => mockFs.restore()); + + it('returns empty object when no cgroup file present', async () => { + mockFs({ + '/proc/self': { + /** empty directory */ + }, + }); + + const collector = new OsCgroupMetricsCollector({}); + expect(await collector.collect()).toEqual({}); + }); + + it('collects default cgroup data', async () => { + mockFs({ + '/proc/self/cgroup': ` +123:memory:/groupname +123:cpu:/groupname +123:cpuacct:/groupname + `, + '/sys/fs/cgroup/cpuacct/groupname/cpuacct.usage': '111', + '/sys/fs/cgroup/cpu/groupname/cpu.cfs_period_us': '222', + '/sys/fs/cgroup/cpu/groupname/cpu.cfs_quota_us': '333', + '/sys/fs/cgroup/cpu/groupname/cpu.stat': ` +nr_periods 444 +nr_throttled 555 +throttled_time 666 + `, + }); + + const collector = new OsCgroupMetricsCollector({}); + expect(await collector.collect()).toMatchInlineSnapshot(` + Object { + "cpu": Object { + "cfs_period_micros": 222, + "cfs_quota_micros": 333, + "control_group": "/groupname", + "stat": Object { + "number_of_elapsed_periods": 444, + "number_of_times_throttled": 555, + "time_throttled_nanos": 666, + }, + }, + "cpuacct": Object { + "control_group": "/groupname", + "usage_nanos": 111, + }, + } + `); + }); + + it('collects override cgroup data', async () => { + mockFs({ + '/proc/self/cgroup': ` +123:memory:/groupname +123:cpu:/groupname +123:cpuacct:/groupname + `, + '/sys/fs/cgroup/cpuacct/xxcustomcpuacctxx/cpuacct.usage': '111', + '/sys/fs/cgroup/cpu/xxcustomcpuxx/cpu.cfs_period_us': '222', + '/sys/fs/cgroup/cpu/xxcustomcpuxx/cpu.cfs_quota_us': '333', + '/sys/fs/cgroup/cpu/xxcustomcpuxx/cpu.stat': ` +nr_periods 444 +nr_throttled 555 +throttled_time 666 + `, + }); + + const collector = new OsCgroupMetricsCollector({ + cpuAcctPath: 'xxcustomcpuacctxx', + cpuPath: 'xxcustomcpuxx', + }); + expect(await collector.collect()).toMatchInlineSnapshot(` + Object { + "cpu": Object { + "cfs_period_micros": 222, + "cfs_quota_micros": 333, + "control_group": "xxcustomcpuxx", + "stat": Object { + "number_of_elapsed_periods": 444, + "number_of_times_throttled": 555, + "time_throttled_nanos": 666, + }, + }, + "cpuacct": Object { + "control_group": "xxcustomcpuacctxx", + "usage_nanos": 111, + }, + } + `); + }); +}); diff --git a/src/core/server/metrics/collectors/cgroup.ts b/src/core/server/metrics/collectors/cgroup.ts new file mode 100644 index 0000000000000..867ea44dff1ae --- /dev/null +++ b/src/core/server/metrics/collectors/cgroup.ts @@ -0,0 +1,194 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import fs from 'fs'; +import { join as joinPath } from 'path'; +import { MetricsCollector, OpsOsMetrics } from './types'; + +type OsCgroupMetrics = Pick; + +interface OsCgroupMetricsCollectorOptions { + cpuPath?: string; + cpuAcctPath?: string; +} + +export class OsCgroupMetricsCollector implements MetricsCollector { + /** Used to prevent unnecessary file reads on systems not using cgroups. */ + private noCgroupPresent = false; + private cpuPath?: string; + private cpuAcctPath?: string; + + constructor(private readonly options: OsCgroupMetricsCollectorOptions) {} + + public async collect(): Promise { + try { + await this.initializePaths(); + if (this.noCgroupPresent || !this.cpuAcctPath || !this.cpuPath) { + return {}; + } + + const [cpuAcctUsage, cpuFsPeriod, cpuFsQuota, cpuStat] = await Promise.all([ + readCPUAcctUsage(this.cpuAcctPath), + readCPUFsPeriod(this.cpuPath), + readCPUFsQuota(this.cpuPath), + readCPUStat(this.cpuPath), + ]); + + return { + cpuacct: { + control_group: this.cpuAcctPath, + usage_nanos: cpuAcctUsage, + }, + + cpu: { + control_group: this.cpuPath, + cfs_period_micros: cpuFsPeriod, + cfs_quota_micros: cpuFsQuota, + stat: cpuStat, + }, + }; + } catch (err) { + if (err.code === 'ENOENT') { + this.noCgroupPresent = true; + return {}; + } else { + throw err; + } + } + } + + public reset() {} + + private async initializePaths() { + // Perform this setup lazily on the first collect call and then memoize the results. + // Makes the assumption this data doesn't change while the process is running. + if (this.cpuPath && this.cpuAcctPath) { + return; + } + + // Only read the file if both options are undefined. + if (!this.options.cpuPath || !this.options.cpuAcctPath) { + const cgroups = await readControlGroups(); + this.cpuPath = this.options.cpuPath || cgroups[GROUP_CPU]; + this.cpuAcctPath = this.options.cpuAcctPath || cgroups[GROUP_CPUACCT]; + } else { + this.cpuPath = this.options.cpuPath; + this.cpuAcctPath = this.options.cpuAcctPath; + } + + // prevents undefined cgroup paths + if (!this.cpuPath || !this.cpuAcctPath) { + this.noCgroupPresent = true; + } + } +} + +const CONTROL_GROUP_RE = new RegExp('\\d+:([^:]+):(/.*)'); +const CONTROLLER_SEPARATOR_RE = ','; + +const PROC_SELF_CGROUP_FILE = '/proc/self/cgroup'; +const PROC_CGROUP_CPU_DIR = '/sys/fs/cgroup/cpu'; +const PROC_CGROUP_CPUACCT_DIR = '/sys/fs/cgroup/cpuacct'; + +const GROUP_CPUACCT = 'cpuacct'; +const CPUACCT_USAGE_FILE = 'cpuacct.usage'; + +const GROUP_CPU = 'cpu'; +const CPU_FS_PERIOD_US_FILE = 'cpu.cfs_period_us'; +const CPU_FS_QUOTA_US_FILE = 'cpu.cfs_quota_us'; +const CPU_STATS_FILE = 'cpu.stat'; + +async function readControlGroups() { + const data = await fs.promises.readFile(PROC_SELF_CGROUP_FILE); + + return data + .toString() + .split(/\n/) + .reduce((acc, line) => { + const matches = line.match(CONTROL_GROUP_RE); + + if (matches !== null) { + const controllers = matches[1].split(CONTROLLER_SEPARATOR_RE); + controllers.forEach((controller) => { + acc[controller] = matches[2]; + }); + } + + return acc; + }, {} as Record); +} + +async function fileContentsToInteger(path: string) { + const data = await fs.promises.readFile(path); + return parseInt(data.toString(), 10); +} + +function readCPUAcctUsage(controlGroup: string) { + return fileContentsToInteger(joinPath(PROC_CGROUP_CPUACCT_DIR, controlGroup, CPUACCT_USAGE_FILE)); +} + +function readCPUFsPeriod(controlGroup: string) { + return fileContentsToInteger(joinPath(PROC_CGROUP_CPU_DIR, controlGroup, CPU_FS_PERIOD_US_FILE)); +} + +function readCPUFsQuota(controlGroup: string) { + return fileContentsToInteger(joinPath(PROC_CGROUP_CPU_DIR, controlGroup, CPU_FS_QUOTA_US_FILE)); +} + +async function readCPUStat(controlGroup: string) { + const stat = { + number_of_elapsed_periods: -1, + number_of_times_throttled: -1, + time_throttled_nanos: -1, + }; + + try { + const data = await fs.promises.readFile( + joinPath(PROC_CGROUP_CPU_DIR, controlGroup, CPU_STATS_FILE) + ); + return data + .toString() + .split(/\n/) + .reduce((acc, line) => { + const fields = line.split(/\s+/); + + switch (fields[0]) { + case 'nr_periods': + acc.number_of_elapsed_periods = parseInt(fields[1], 10); + break; + + case 'nr_throttled': + acc.number_of_times_throttled = parseInt(fields[1], 10); + break; + + case 'throttled_time': + acc.time_throttled_nanos = parseInt(fields[1], 10); + break; + } + + return acc; + }, stat); + } catch (err) { + if (err.code === 'ENOENT') { + return stat; + } + + throw err; + } +} diff --git a/src/core/server/metrics/collectors/collector.mock.ts b/src/core/server/metrics/collectors/collector.mock.ts new file mode 100644 index 0000000000000..2a942e1fafe63 --- /dev/null +++ b/src/core/server/metrics/collectors/collector.mock.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { MetricsCollector } from './types'; + +const createCollector = (collectReturnValue: any = {}): jest.Mocked> => { + const collector: jest.Mocked> = { + collect: jest.fn().mockResolvedValue(collectReturnValue), + reset: jest.fn(), + }; + + return collector; +}; + +export const metricsCollectorMock = { + create: createCollector, +}; diff --git a/src/core/server/metrics/collectors/index.ts b/src/core/server/metrics/collectors/index.ts index f58ab02e63881..4540cb79be74b 100644 --- a/src/core/server/metrics/collectors/index.ts +++ b/src/core/server/metrics/collectors/index.ts @@ -18,6 +18,6 @@ */ export { OpsProcessMetrics, OpsOsMetrics, OpsServerMetrics, MetricsCollector } from './types'; -export { OsMetricsCollector } from './os'; +export { OsMetricsCollector, OpsMetricsCollectorOptions } from './os'; export { ProcessMetricsCollector } from './process'; export { ServerMetricsCollector } from './server'; diff --git a/src/core/server/metrics/collectors/os.test.mocks.ts b/src/core/server/metrics/collectors/os.test.mocks.ts new file mode 100644 index 0000000000000..ee02b8c802151 --- /dev/null +++ b/src/core/server/metrics/collectors/os.test.mocks.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { metricsCollectorMock } from './collector.mock'; + +export const cgroupCollectorMock = metricsCollectorMock.create(); +jest.doMock('./cgroup', () => ({ + OsCgroupMetricsCollector: jest.fn(() => cgroupCollectorMock), +})); diff --git a/src/core/server/metrics/collectors/os.test.ts b/src/core/server/metrics/collectors/os.test.ts index 7d5a6da90b7d6..5e52cecb76be3 100644 --- a/src/core/server/metrics/collectors/os.test.ts +++ b/src/core/server/metrics/collectors/os.test.ts @@ -20,6 +20,7 @@ jest.mock('getos', () => (cb: Function) => cb(null, { dist: 'distrib', release: 'release' })); import os from 'os'; +import { cgroupCollectorMock } from './os.test.mocks'; import { OsMetricsCollector } from './os'; describe('OsMetricsCollector', () => { @@ -27,6 +28,8 @@ describe('OsMetricsCollector', () => { beforeEach(() => { collector = new OsMetricsCollector(); + cgroupCollectorMock.collect.mockReset(); + cgroupCollectorMock.reset.mockReset(); }); afterEach(() => { @@ -96,4 +99,9 @@ describe('OsMetricsCollector', () => { '15m': fifteenMinLoad, }); }); + + it('calls the cgroup sub-collector', async () => { + await collector.collect(); + expect(cgroupCollectorMock.collect).toHaveBeenCalled(); + }); }); diff --git a/src/core/server/metrics/collectors/os.ts b/src/core/server/metrics/collectors/os.ts index 59bef9d8ddd2b..eae49278405a9 100644 --- a/src/core/server/metrics/collectors/os.ts +++ b/src/core/server/metrics/collectors/os.ts @@ -21,10 +21,22 @@ import os from 'os'; import getosAsync, { LinuxOs } from 'getos'; import { promisify } from 'util'; import { OpsOsMetrics, MetricsCollector } from './types'; +import { OsCgroupMetricsCollector } from './cgroup'; const getos = promisify(getosAsync); +export interface OpsMetricsCollectorOptions { + cpuPath?: string; + cpuAcctPath?: string; +} + export class OsMetricsCollector implements MetricsCollector { + private readonly cgroupCollector: OsCgroupMetricsCollector; + + constructor(options: OpsMetricsCollectorOptions = {}) { + this.cgroupCollector = new OsCgroupMetricsCollector(options); + } + public async collect(): Promise { const platform = os.platform(); const load = os.loadavg(); @@ -43,20 +55,30 @@ export class OsMetricsCollector implements MetricsCollector { used_in_bytes: os.totalmem() - os.freemem(), }, uptime_in_millis: os.uptime() * 1000, + ...(await this.getDistroStats(platform)), + ...(await this.cgroupCollector.collect()), }; + return metrics; + } + + public reset() {} + + private async getDistroStats( + platform: string + ): Promise> { if (platform === 'linux') { try { const distro = (await getos()) as LinuxOs; - metrics.distro = distro.dist; - metrics.distroRelease = `${distro.dist}-${distro.release}`; + return { + distro: distro.dist, + distroRelease: `${distro.dist}-${distro.release}`, + }; } catch (e) { // ignore errors } } - return metrics; + return {}; } - - public reset() {} } diff --git a/src/core/server/metrics/collectors/types.ts b/src/core/server/metrics/collectors/types.ts index 73e8975a6b362..77ea13a1f0787 100644 --- a/src/core/server/metrics/collectors/types.ts +++ b/src/core/server/metrics/collectors/types.ts @@ -85,6 +85,33 @@ export interface OpsOsMetrics { }; /** the OS uptime */ uptime_in_millis: number; + + /** cpu accounting metrics, undefined when not running in a cgroup */ + cpuacct?: { + /** name of this process's cgroup */ + control_group: string; + /** cpu time used by this process's cgroup */ + usage_nanos: number; + }; + + /** cpu cgroup metrics, undefined when not running in a cgroup */ + cpu?: { + /** name of this process's cgroup */ + control_group: string; + /** the length of the cfs period */ + cfs_period_micros: number; + /** total available run-time within a cfs period */ + cfs_quota_micros: number; + /** current stats on the cfs periods */ + stat: { + /** number of cfs periods that elapsed */ + number_of_elapsed_periods: number; + /** number of times the cgroup has been throttled */ + number_of_times_throttled: number; + /** total amount of time the cgroup has been throttled for */ + time_throttled_nanos: number; + }; + }; } /** diff --git a/src/core/server/metrics/metrics_service.mock.ts b/src/core/server/metrics/metrics_service.mock.ts index 769f6ee2a549a..2af653004a479 100644 --- a/src/core/server/metrics/metrics_service.mock.ts +++ b/src/core/server/metrics/metrics_service.mock.ts @@ -21,20 +21,18 @@ import { MetricsService } from './metrics_service'; import { InternalMetricsServiceSetup, InternalMetricsServiceStart, + MetricsServiceSetup, MetricsServiceStart, } from './types'; const createInternalSetupContractMock = () => { - const setupContract: jest.Mocked = {}; - return setupContract; -}; - -const createStartContractMock = () => { - const startContract: jest.Mocked = { + const setupContract: jest.Mocked = { + collectionInterval: 30000, getOpsMetrics$: jest.fn(), }; - startContract.getOpsMetrics$.mockReturnValue( + setupContract.getOpsMetrics$.mockReturnValue( new BehaviorSubject({ + collected_at: new Date('2020-01-01 01:00:00'), process: { memory: { heap: { total_in_bytes: 1, used_in_bytes: 1, size_limit: 1 }, @@ -56,11 +54,21 @@ const createStartContractMock = () => { concurrent_connections: 1, }) ); + return setupContract; +}; + +const createSetupContractMock = () => { + const startContract: jest.Mocked = createInternalSetupContractMock(); return startContract; }; const createInternalStartContractMock = () => { - const startContract: jest.Mocked = createStartContractMock(); + const startContract: jest.Mocked = createInternalSetupContractMock(); + return startContract; +}; + +const createStartContractMock = () => { + const startContract: jest.Mocked = createInternalSetupContractMock(); return startContract; }; @@ -77,7 +85,7 @@ const createMock = () => { export const metricsServiceMock = { create: createMock, - createSetupContract: createStartContractMock, + createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, createInternalSetupContract: createInternalSetupContractMock, createInternalStartContract: createInternalStartContractMock, diff --git a/src/core/server/metrics/metrics_service.ts b/src/core/server/metrics/metrics_service.ts index f28fb21aaac0d..d4696b3aa9aaf 100644 --- a/src/core/server/metrics/metrics_service.ts +++ b/src/core/server/metrics/metrics_service.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Subject } from 'rxjs'; +import { ReplaySubject } from 'rxjs'; import { first } from 'rxjs/operators'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; @@ -37,26 +37,21 @@ export class MetricsService private readonly logger: Logger; private metricsCollector?: OpsMetricsCollector; private collectInterval?: NodeJS.Timeout; - private metrics$ = new Subject(); + private metrics$ = new ReplaySubject(); + private service?: InternalMetricsServiceSetup; constructor(private readonly coreContext: CoreContext) { this.logger = coreContext.logger.get('metrics'); } public async setup({ http }: MetricsServiceSetupDeps): Promise { - this.metricsCollector = new OpsMetricsCollector(http.server); - return {}; - } - - public async start(): Promise { - if (!this.metricsCollector) { - throw new Error('#setup() needs to be run first'); - } const config = await this.coreContext.configService .atPath(opsConfig.path) .pipe(first()) .toPromise(); + this.metricsCollector = new OpsMetricsCollector(http.server, config.cGroupOverrides); + await this.refreshMetrics(); this.collectInterval = setInterval(() => { @@ -65,9 +60,20 @@ export class MetricsService const metricsObservable = this.metrics$.asObservable(); - return { + this.service = { + collectionInterval: config.interval.asMilliseconds(), getOpsMetrics$: () => metricsObservable, }; + + return this.service; + } + + public async start(): Promise { + if (!this.service) { + throw new Error('#setup() needs to be run first'); + } + + return this.service; } private async refreshMetrics() { diff --git a/src/core/server/metrics/ops_config.ts b/src/core/server/metrics/ops_config.ts index bd6ae5cc5474d..5f3f67e931c38 100644 --- a/src/core/server/metrics/ops_config.ts +++ b/src/core/server/metrics/ops_config.ts @@ -23,6 +23,10 @@ export const opsConfig = { path: 'ops', schema: schema.object({ interval: schema.duration({ defaultValue: '5s' }), + cGroupOverrides: schema.object({ + cpuPath: schema.maybe(schema.string()), + cpuAcctPath: schema.maybe(schema.string()), + }), }), }; diff --git a/src/core/server/metrics/ops_metrics_collector.test.ts b/src/core/server/metrics/ops_metrics_collector.test.ts index 9e76895b14578..7aa3f7cd3baf0 100644 --- a/src/core/server/metrics/ops_metrics_collector.test.ts +++ b/src/core/server/metrics/ops_metrics_collector.test.ts @@ -30,7 +30,7 @@ describe('OpsMetricsCollector', () => { beforeEach(() => { const hapiServer = httpServiceMock.createInternalSetupContract().server; - collector = new OpsMetricsCollector(hapiServer); + collector = new OpsMetricsCollector(hapiServer, {}); mockOsCollector.collect.mockResolvedValue('osMetrics'); }); @@ -51,6 +51,7 @@ describe('OpsMetricsCollector', () => { expect(mockServerCollector.collect).toHaveBeenCalledTimes(1); expect(metrics).toEqual({ + collected_at: expect.any(Date), process: 'processMetrics', os: 'osMetrics', requests: 'serverRequestsMetrics', diff --git a/src/core/server/metrics/ops_metrics_collector.ts b/src/core/server/metrics/ops_metrics_collector.ts index 525515dba1457..af74caa6cb386 100644 --- a/src/core/server/metrics/ops_metrics_collector.ts +++ b/src/core/server/metrics/ops_metrics_collector.ts @@ -21,6 +21,7 @@ import { Server as HapiServer } from 'hapi'; import { ProcessMetricsCollector, OsMetricsCollector, + OpsMetricsCollectorOptions, ServerMetricsCollector, MetricsCollector, } from './collectors'; @@ -31,9 +32,9 @@ export class OpsMetricsCollector implements MetricsCollector { private readonly osCollector: OsMetricsCollector; private readonly serverCollector: ServerMetricsCollector; - constructor(server: HapiServer) { + constructor(server: HapiServer, opsOptions: OpsMetricsCollectorOptions) { this.processCollector = new ProcessMetricsCollector(); - this.osCollector = new OsMetricsCollector(); + this.osCollector = new OsMetricsCollector(opsOptions); this.serverCollector = new ServerMetricsCollector(server); } @@ -44,6 +45,7 @@ export class OpsMetricsCollector implements MetricsCollector { this.serverCollector.collect(), ]); return { + collected_at: new Date(), process, os, ...server, diff --git a/src/core/server/metrics/types.ts b/src/core/server/metrics/types.ts index cbf0acacd6bab..c177b3ed25115 100644 --- a/src/core/server/metrics/types.ts +++ b/src/core/server/metrics/types.ts @@ -20,14 +20,15 @@ import { Observable } from 'rxjs'; import { OpsProcessMetrics, OpsOsMetrics, OpsServerMetrics } from './collectors'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface MetricsServiceSetup {} /** * APIs to retrieves metrics gathered and exposed by the core platform. * * @public */ -export interface MetricsServiceStart { +export interface MetricsServiceSetup { + /** Interval metrics are collected in milliseconds */ + readonly collectionInterval: number; + /** * Retrieve an observable emitting the {@link OpsMetrics} gathered. * The observable will emit an initial value during core's `start` phase, and a new value every fixed interval of time, @@ -42,6 +43,12 @@ export interface MetricsServiceStart { */ getOpsMetrics$: () => Observable; } +/** + * {@inheritdoc MetricsServiceSetup} + * + * @public + */ +export type MetricsServiceStart = MetricsServiceSetup; export type InternalMetricsServiceSetup = MetricsServiceSetup; export type InternalMetricsServiceStart = MetricsServiceStart; @@ -53,6 +60,8 @@ export type InternalMetricsServiceStart = MetricsServiceStart; * @public */ export interface OpsMetrics { + /** Time metrics were recorded at. */ + collected_at: Date; /** Process related metrics */ process: OpsProcessMetrics; /** OS related metrics */ diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index fa2659ca130a0..5c389855d9ea2 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -233,6 +233,7 @@ export function createPluginStartContext( getTypeRegistry: deps.savedObjects.getTypeRegistry, }, metrics: { + collectionInterval: deps.metrics.collectionInterval, getOpsMetrics$: deps.metrics.getOpsMetrics$, }, uiSettings: { diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 37023a0a8ef67..b86cc14636b8c 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1531,10 +1531,10 @@ export interface LogRecord { timestamp: Date; } -// Warning: (ae-missing-release-tag) "MetricsServiceSetup" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export interface MetricsServiceSetup { + readonly collectionInterval: number; + getOpsMetrics$: () => Observable; } // @public @deprecated (undocumented) @@ -1621,6 +1621,7 @@ export interface OnPreRoutingToolkit { // @public export interface OpsMetrics { + collected_at: Date; concurrent_connections: OpsServerMetrics['concurrent_connections']; os: OpsOsMetrics; process: OpsProcessMetrics; @@ -1630,6 +1631,20 @@ export interface OpsMetrics { // @public export interface OpsOsMetrics { + cpu?: { + control_group: string; + cfs_period_micros: number; + cfs_quota_micros: number; + stat: { + number_of_elapsed_periods: number; + number_of_times_throttled: number; + time_throttled_nanos: number; + }; + }; + cpuacct?: { + control_group: string; + usage_nanos: number; + }; distro?: string; distroRelease?: string; load: { diff --git a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts index 359d3a396665d..a527d4d03c6fc 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/index.test.ts @@ -39,6 +39,7 @@ describe('telemetry_ops_stats', () => { const callCluster = jest.fn(); const metric: OpsMetrics = { + collected_at: new Date('2020-01-01 01:00:00'), process: { memory: { heap: { diff --git a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/ops_stats_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/ops_stats_collector.ts index 6e8b71d675f7b..d3be601540582 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/ops_stats_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/ops_stats_collector.ts @@ -18,13 +18,13 @@ */ import { Observable } from 'rxjs'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, omit } from 'lodash'; import moment from 'moment'; import { OpsMetrics } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { KIBANA_STATS_TYPE } from '../../../common/constants'; -interface OpsStatsMetrics extends Omit { +interface OpsStatsMetrics extends Omit { timestamp: string; response_times: { average: number; @@ -52,9 +52,9 @@ export function getOpsStatsCollector( // @ts-expect-error delete metrics.requests.statusCodes; lastMetrics = { - ...metrics, + ...omit(metrics, ['collected_at']), response_times: responseTimes, - timestamp: moment.utc().toISOString(), + timestamp: moment.utc(metrics.collected_at).toISOString(), }; }); From 791a73c1eb3b381dfc151e678bacc89cbae63601 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 9 Sep 2020 14:42:30 -0700 Subject: [PATCH 17/25] Reporting/Test: unskip non-screenshot tests (#77088) --- .../reporting_and_security/spaces.ts | 9 +++++---- .../reporting_and_security/usage.ts | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/spaces.ts b/x-pack/test/reporting_api_integration/reporting_and_security/spaces.ts index 6a68bd530cf63..9eafd0c318383 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/spaces.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/spaces.ts @@ -27,8 +27,7 @@ export default function ({ getService }: FtrProviderContext) { ); }; - // FLAKY: https://github.com/elastic/kibana/issues/76551 - describe.skip('Exports from Non-default Space', () => { + describe('Exports from Non-default Space', () => { before(async () => { await esArchiver.load('reporting/ecommerce'); await esArchiver.load('reporting/ecommerce_kibana_spaces'); // dashboard in non default space @@ -54,7 +53,8 @@ export default function ({ getService }: FtrProviderContext) { expect(reportCompleted).to.match(/^"order_date",/); }); - it('should complete a job of PNG export of a dashboard in non-default space', async () => { + // FLAKY: https://github.com/elastic/kibana/issues/76551 + it.skip('should complete a job of PNG export of a dashboard in non-default space', async () => { const downloadPath = await reportingAPI.postJob( `/s/non_default_space/api/reporting/generate/png?jobParams=%28browserTimezone%3AUTC%2Clayout%3A%28dimensions%3A%28height%3A512%2Cwidth%3A2402%29%2Cid%3Apng%29%2CobjectType%3Adashboard%2CrelativeUrl%3A%27%2Fs%2Fnon_default_space%2Fapp%2Fdashboards%23%2Fview%2F3c9ee360-e7ee-11ea-a730-d58e9ea7581b%3F_g%3D%28filters%3A%21%21%28%29%2CrefreshInterval%3A%28pause%3A%21%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3A%21%272019-06-10T03%3A17%3A28.800Z%21%27%2Cto%3A%21%272019-07-14T19%3A25%3A06.385Z%21%27%29%29%26_a%3D%28description%3A%21%27%21%27%2Cfilters%3A%21%21%28%29%2CfullScreenMode%3A%21%21f%2Coptions%3A%28hidePanelTitles%3A%21%21f%2CuseMargins%3A%21%21t%29%2Cquery%3A%28language%3Akuery%2Cquery%3A%21%27%21%27%29%2CtimeRestore%3A%21%21t%2Ctitle%3A%21%27Ecom%2520Dashboard%2520Non%2520Default%2520Space%21%27%2CviewMode%3Aview%29%27%2Ctitle%3A%27Ecom%20Dashboard%20Non%20Default%20Space%27%29` ); @@ -64,7 +64,8 @@ export default function ({ getService }: FtrProviderContext) { expect(reportCompleted).to.not.be(null); }); - it('should complete a job of PDF export of a dashboard in non-default space', async () => { + // FLAKY: https://github.com/elastic/kibana/issues/76551 + it.skip('should complete a job of PDF export of a dashboard in non-default space', async () => { const downloadPath = await reportingAPI.postJob( `/s/non_default_space/api/reporting/generate/printablePdf?jobParams=%28browserTimezone%3AUTC%2Clayout%3A%28dimensions%3A%28height%3A512%2Cwidth%3A2402%29%2Cid%3Apreserve_layout%29%2CobjectType%3Adashboard%2CrelativeUrls%3A%21%28%27%2Fs%2Fnon_default_space%2Fapp%2Fdashboards%23%2Fview%2F3c9ee360-e7ee-11ea-a730-d58e9ea7581b%3F_g%3D%28filters%3A%21%21%28%29%2CrefreshInterval%3A%28pause%3A%21%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3A%21%272019-06-10T03%3A17%3A28.800Z%21%27%2Cto%3A%21%272019-07-14T19%3A25%3A06.385Z%21%27%29%29%26_a%3D%28description%3A%21%27%21%27%2Cfilters%3A%21%21%28%29%2CfullScreenMode%3A%21%21f%2Coptions%3A%28hidePanelTitles%3A%21%21f%2CuseMargins%3A%21%21t%29%2Cquery%3A%28language%3Akuery%2Cquery%3A%21%27%21%27%29%2CtimeRestore%3A%21%21t%2Ctitle%3A%21%27Ecom%2520Dashboard%2520Non%2520Default%2520Space%21%27%2CviewMode%3Aview%29%27%29%2Ctitle%3A%27Ecom%20Dashboard%20Non%20Default%20Space%27%29` ); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts index 49db8696c1134..aaf4dd3926411 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage.ts @@ -21,8 +21,7 @@ export default function ({ getService }: FtrProviderContext) { const reportingAPI = getService('reportingAPI'); const usageAPI = getService('usageAPI'); - // FAILING: https://github.com/elastic/kibana/issues/76581 - describe.skip('Usage', () => { + describe('Usage', () => { before(async () => { await esArchiver.load(OSS_KIBANA_ARCHIVE_PATH); await esArchiver.load(OSS_DATA_ARCHIVE_PATH); @@ -116,7 +115,8 @@ export default function ({ getService }: FtrProviderContext) { }); }); - describe('from new jobs posted', () => { + // FAILING: https://github.com/elastic/kibana/issues/76581 + describe.skip('from new jobs posted', () => { it('should handle csv', async () => { await reportingAPI.expectAllJobsToFinishSuccessfully( await Promise.all([ From 9edb8d9ae296506572d97ce3ac3a1736d6202b6e Mon Sep 17 00:00:00 2001 From: Joel Griffith Date: Wed, 9 Sep 2020 16:11:23 -0700 Subject: [PATCH 18/25] Reporting/diagnostics (#74314) * WIP: Adding in new reporting diag tool * WIP: chrome-binary test + log capturing/error handling * More wip on diagnostic tool * More work adding in diagnose routes * Alter link in description + minor rename of chrome => browser * Wiring UI to API + some polish on UI flow * WIP: Add in screenshot diag route * Adding in screenshot diag route, hooking up client to it * Add missing lib check + memory check * Working screenshot test + config check for RAM * Small test helper consolidation + screenshot diag test * Delete old i18n translations * PR feedback, browser tests, rename, re-organize import statements and lite fixes * Lite rename for consistency * Remove old validate check i18n * Add config check * i18n all the things! * Docs on diagnostics tool * Fixes, better readability, spelling and more for diagnostic tool * Translate a few error messages * Rename of test => start_logs for clarity. Move to observables * Gathering logs even during process exit or crash * Adds a test case for the browser exiting during the diag check * Tap into browser logs for checking output * Rename asciidoc diag id * Remove duplicate shared object message * Add better comment as to why we merge events + wait for a period of time * Cloning logger for mirroring browser stderr to kibana output Co-authored-by: Elastic Machine --- .../reporting-troubleshooting.asciidoc | 6 + x-pack/plugins/reporting/common/constants.ts | 1 + .../public/components/report_diagnostic.tsx | 281 ++++++++++++++++++ .../public/components/report_listing.tsx | 52 ++-- .../public/lib/reporting_api_client.ts | 37 ++- .../browsers/chromium/driver_factory/index.ts | 22 -- .../chromium/driver_factory/start_logs.ts | 133 +++++++++ .../reporting/server/browsers/install.ts | 27 +- .../export_types/png/lib/generate_png.ts | 2 +- x-pack/plugins/reporting/server/lib/index.ts | 1 - .../reporting/server/lib/layouts/index.ts | 1 + .../server/lib/layouts/preserve_layout.ts | 6 +- .../reporting/server/lib/store/store.test.ts | 3 +- .../reporting/server/lib/validate/index.ts | 43 --- .../server/lib/validate/validate_browser.ts | 29 -- .../validate_max_content_length.test.js | 80 ----- .../validate/validate_max_content_length.ts | 40 --- x-pack/plugins/reporting/server/plugin.ts | 6 +- .../server/routes/diagnostic/browser.test.ts | 250 ++++++++++++++++ .../server/routes/diagnostic/browser.ts | 78 +++++ .../server/routes/diagnostic/config.test.ts | 107 +++++++ .../server/routes/diagnostic/config.ts | 81 +++++ .../server/routes/diagnostic/index.ts | 17 ++ .../routes/diagnostic/screenshot.test.ts | 112 +++++++ .../server/routes/diagnostic/screenshot.ts | 116 ++++++++ .../server/routes/generation.test.ts | 3 +- .../plugins/reporting/server/routes/index.ts | 2 + .../create_mock_reportingplugin.ts | 1 - .../reporting/server/test_helpers/index.ts | 1 + x-pack/plugins/reporting/server/types.ts | 6 + .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 32 files changed, 1295 insertions(+), 253 deletions(-) create mode 100644 x-pack/plugins/reporting/public/components/report_diagnostic.tsx create mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts delete mode 100644 x-pack/plugins/reporting/server/lib/validate/index.ts delete mode 100644 x-pack/plugins/reporting/server/lib/validate/validate_browser.ts delete mode 100644 x-pack/plugins/reporting/server/lib/validate/validate_max_content_length.test.js delete mode 100644 x-pack/plugins/reporting/server/lib/validate/validate_max_content_length.ts create mode 100644 x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts create mode 100644 x-pack/plugins/reporting/server/routes/diagnostic/browser.ts create mode 100644 x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts create mode 100644 x-pack/plugins/reporting/server/routes/diagnostic/config.ts create mode 100644 x-pack/plugins/reporting/server/routes/diagnostic/index.ts create mode 100644 x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts create mode 100644 x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts diff --git a/docs/user/reporting/reporting-troubleshooting.asciidoc b/docs/user/reporting/reporting-troubleshooting.asciidoc index dc4ffdfebdae9..82f0aa7ca0f19 100644 --- a/docs/user/reporting/reporting-troubleshooting.asciidoc +++ b/docs/user/reporting/reporting-troubleshooting.asciidoc @@ -7,6 +7,7 @@ Having trouble? Here are solutions to common problems you might encounter while using Reporting. +* <> * <> * <> * <> @@ -15,6 +16,11 @@ Having trouble? Here are solutions to common problems you might encounter while * <> * <> +[float] +[[reporting-diagnostics]] +=== Reporting Diagnostics +Reporting comes with a built-in utility to try to automatically find common issues. When Kibana is running, navigate to the Report Listing page, and click the "Run reporting diagnostics..." button. This will open up a diagnostic tool that checks various parts of the Kibana deployment to come up with any relevant recommendations. + [float] [[reporting-troubleshooting-system-dependencies]] === System dependencies diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index c461c2de4e2ad..e5bca43cef562 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -16,6 +16,7 @@ export const API_BASE_URL_V1 = '/api/reporting/v1'; // export const API_BASE_GENERATE_V1 = `${API_BASE_URL_V1}/generate`; export const API_LIST_URL = '/api/reporting/jobs'; export const API_GENERATE_IMMEDIATE = `${API_BASE_URL_V1}/generate/immediate/csv/saved-object`; +export const API_DIAGNOSE_URL = `${API_BASE_URL}/diagnose`; export const CONTENT_TYPE_CSV = 'text/csv'; export const CSV_REPORTING_ACTION = 'downloadCsvReport'; diff --git a/x-pack/plugins/reporting/public/components/report_diagnostic.tsx b/x-pack/plugins/reporting/public/components/report_diagnostic.tsx new file mode 100644 index 0000000000000..b5b055207ddbb --- /dev/null +++ b/x-pack/plugins/reporting/public/components/report_diagnostic.tsx @@ -0,0 +1,281 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useState, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiCodeBlock, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiSpacer, + EuiSteps, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { ReportingAPIClient, DiagnoseResponse } from '../lib/reporting_api_client'; + +interface Props { + apiClient: ReportingAPIClient; +} + +type ResultStatus = 'danger' | 'incomplete' | 'complete'; + +enum statuses { + configStatus = 'configStatus', + chromeStatus = 'chromeStatus', + screenshotStatus = 'screenshotStatus', +} + +interface State { + isFlyoutVisible: boolean; + configStatus: ResultStatus; + chromeStatus: ResultStatus; + screenshotStatus: ResultStatus; + help: string[]; + logs: string; + isBusy: boolean; + success: boolean; +} + +const initialState: State = { + [statuses.configStatus]: 'incomplete', + [statuses.chromeStatus]: 'incomplete', + [statuses.screenshotStatus]: 'incomplete', + isFlyoutVisible: false, + help: [], + logs: '', + isBusy: false, + success: true, +}; + +export const ReportDiagnostic = ({ apiClient }: Props) => { + const [state, setStateBase] = useState(initialState); + const setState = (s: Partial) => + setStateBase({ + ...state, + ...s, + }); + const { + configStatus, + isBusy, + screenshotStatus, + chromeStatus, + isFlyoutVisible, + help, + logs, + success, + } = state; + + const closeFlyout = () => setState({ ...initialState, isFlyoutVisible: false }); + const showFlyout = () => setState({ isFlyoutVisible: true }); + const apiWrapper = (apiMethod: () => Promise, statusProp: statuses) => () => { + setState({ isBusy: true, [statusProp]: 'incomplete' }); + apiMethod() + .then((response) => { + setState({ + isBusy: false, + help: response.help, + logs: response.logs, + success: response.success, + [statusProp]: response.success ? 'complete' : 'danger', + }); + }) + .catch((error) => { + setState({ + isBusy: false, + help: [ + i18n.translate('xpack.reporting.listing.diagnosticApiCallFailure', { + defaultMessage: `There was a problem running the diagnostic: {error}`, + values: { error }, + }), + ], + logs: `${error.message}`, + success: false, + [statusProp]: 'danger', + }); + }); + }; + + const steps = [ + { + title: i18n.translate('xpack.reporting.listing.diagnosticConfigTitle', { + defaultMessage: 'Verify Kibana Configuration', + }), + children: ( + + + + + + + + ), + status: !success && configStatus !== 'complete' ? 'danger' : configStatus, + }, + ]; + + if (configStatus === 'complete') { + steps.push({ + title: i18n.translate('xpack.reporting.listing.diagnosticBrowserTitle', { + defaultMessage: 'Check Browser', + }), + children: ( + + + + + + + + ), + status: !success && chromeStatus !== 'complete' ? 'danger' : chromeStatus, + }); + } + + if (chromeStatus === 'complete') { + steps.push({ + title: i18n.translate('xpack.reporting.listing.diagnosticScreenshotTitle', { + defaultMessage: 'Check Screen Capture Capabilities', + }), + children: ( + + + + + + + + ), + status: !success && screenshotStatus !== 'complete' ? 'danger' : screenshotStatus, + }); + } + + if (screenshotStatus === 'complete') { + steps.push({ + title: i18n.translate('xpack.reporting.listing.diagnosticSuccessTitle', { + defaultMessage: 'All set!', + }), + children: ( + + + + ), + status: !success ? 'danger' : screenshotStatus, + }); + } + + if (!success) { + steps.push({ + title: i18n.translate('xpack.reporting.listing.diagnosticFailureTitle', { + defaultMessage: "Whoops! Looks like something isn't working properly.", + }), + children: ( + + {help.length ? ( + + +

{help.join('\n')}

+
+
+ ) : null} + {logs.length ? ( + + + + + {logs} + + ) : null} +
+ ), + status: 'danger', + }); + } + + let flyout; + if (isFlyoutVisible) { + flyout = ( + + + +

+ +

+
+ + + + +
+ + + +
+ ); + } + return ( +
+ {flyout} + + + +
+ ); +}; diff --git a/x-pack/plugins/reporting/public/components/report_listing.tsx b/x-pack/plugins/reporting/public/components/report_listing.tsx index afcae93a8db16..65db13f22788b 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.tsx @@ -6,6 +6,8 @@ import { EuiBasicTable, + EuiFlexItem, + EuiFlexGroup, EuiPageContent, EuiSpacer, EuiText, @@ -31,6 +33,7 @@ import { ReportErrorButton, ReportInfoButton, } from './buttons'; +import { ReportDiagnostic } from './report_diagnostic'; export interface Job { id: string; @@ -134,23 +137,38 @@ class ReportListingUi extends Component { public render() { return ( - - -

- -

-
- -

- -

-
- - {this.renderTable()} -
+
+ + + + +

+ +

+
+ +

+ +

+
+
+
+ + {this.renderTable()} +
+ + + + + + +
); } diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts index 54bdc99532320..2f813bd811c6c 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts @@ -8,7 +8,12 @@ import { stringify } from 'query-string'; import rison from 'rison-node'; import { HttpSetup } from 'src/core/public'; import { JobId, SourceJob } from '../../common/types'; -import { API_BASE_GENERATE, API_LIST_URL, REPORTING_MANAGEMENT_HOME } from '../../constants'; +import { + API_BASE_URL, + API_BASE_GENERATE, + API_LIST_URL, + REPORTING_MANAGEMENT_HOME, +} from '../../constants'; import { add } from './job_completion_notifications'; export interface JobQueueEntry { @@ -59,6 +64,12 @@ interface JobParams { [paramName: string]: any; } +export interface DiagnoseResponse { + help: string[]; + success: boolean; + logs: string; +} + export class ReportingAPIClient { private http: HttpSetup; @@ -157,4 +168,28 @@ export class ReportingAPIClient { * provides the raw server basePath to allow it to be stripped out from relativeUrls in job params */ public getServerBasePath = () => this.http.basePath.serverBasePath; + + /* + * Diagnostic-related API calls + */ + public verifyConfig = (): Promise => + this.http.post(`${API_BASE_URL}/diagnose/config`, { + asSystemRequest: true, + }); + + /* + * Diagnostic-related API calls + */ + public verifyBrowser = (): Promise => + this.http.post(`${API_BASE_URL}/diagnose/browser`, { + asSystemRequest: true, + }); + + /* + * Diagnostic-related API calls + */ + public verifyScreenCapture = (): Promise => + this.http.post(`${API_BASE_URL}/diagnose/screenshot`, { + asSystemRequest: true, + }); } diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index 809bfb57dd4fa..88be86d1ecc30 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -59,28 +59,6 @@ export class HeadlessChromiumDriverFactory { type = BROWSER_TYPE; - test(logger: LevelLogger) { - const chromiumArgs = args({ - userDataDir: this.userDataDir, - viewport: { width: 800, height: 600 }, - disableSandbox: this.browserConfig.disableSandbox, - proxy: this.browserConfig.proxy, - }); - - return puppeteerLaunch({ - userDataDir: this.userDataDir, - executablePath: this.binaryPath, - ignoreHTTPSErrors: true, - args: chromiumArgs, - } as LaunchOptions).catch((error: Error) => { - logger.error( - `The Reporting plugin encountered issues launching Chromium in a self-test. You may have trouble generating reports.` - ); - logger.error(error); - return null; - }); - } - /* * Return an observable to objects which will drive screenshot capture for a page */ diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts new file mode 100644 index 0000000000000..8eafbd8e0ddbe --- /dev/null +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { spawn } from 'child_process'; +import del from 'del'; +import { mkdtempSync } from 'fs'; +import { uniq } from 'lodash'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { createInterface } from 'readline'; +import { fromEvent, timer, merge, of } from 'rxjs'; +import { takeUntil, map, reduce, tap, catchError } from 'rxjs/operators'; +import { ReportingCore } from '../../..'; +import { LevelLogger } from '../../../lib'; +import { getBinaryPath } from '../../install'; +import { args } from './args'; + +const browserLaunchTimeToWait = 5 * 1000; + +// Default args used by pptr +// https://github.com/puppeteer/puppeteer/blob/13ea347/src/node/Launcher.ts#L168 +const defaultArgs = [ + '--disable-background-networking', + '--enable-features=NetworkService,NetworkServiceInProcess', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-features=TranslateUI', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--force-color-profile=srgb', + '--metrics-recording-only', + '--no-first-run', + '--enable-automation', + '--password-store=basic', + '--use-mock-keychain', + '--remote-debugging-port=0', + '--headless', +]; + +export const browserStartLogs = ( + core: ReportingCore, + logger: LevelLogger, + overrideFlags: string[] = [] +) => { + const config = core.getConfig(); + const proxy = config.get('capture', 'browser', 'chromium', 'proxy'); + const disableSandbox = config.get('capture', 'browser', 'chromium', 'disableSandbox'); + const userDataDir = mkdtempSync(join(tmpdir(), 'chromium-')); + const binaryPath = getBinaryPath(); + const kbnArgs = args({ + userDataDir, + viewport: { width: 800, height: 600 }, + disableSandbox, + proxy, + }); + const finalArgs = uniq([...defaultArgs, ...kbnArgs, ...overrideFlags]); + + // On non-windows platforms, `detached: true` makes child process a + // leader of a new process group, making it possible to kill child + // process tree with `.kill(-pid)` command. @see + // https://nodejs.org/api/child_process.html#child_process_options_detached + const browserProcess = spawn(binaryPath, finalArgs, { + detached: process.platform !== 'win32', + }); + + const rl = createInterface({ input: browserProcess.stderr }); + + const exit$ = fromEvent(browserProcess, 'exit').pipe( + map((code) => { + logger.error(`Browser exited abnormally, received code: ${code}`); + return i18n.translate('xpack.reporting.diagnostic.browserCrashed', { + defaultMessage: `Browser exited abnormally during startup`, + }); + }) + ); + + const error$ = fromEvent(browserProcess, 'error').pipe( + map(() => { + logger.error(`Browser process threw an error on startup`); + return i18n.translate('xpack.reporting.diagnostic.browserErrored', { + defaultMessage: `Browser process threw an error on startup`, + }); + }) + ); + + const browserProcessLogger = logger.clone(['chromium-stderr']); + const log$ = fromEvent(rl, 'line').pipe( + tap((message: unknown) => { + if (typeof message === 'string') { + browserProcessLogger.info(message); + } + }) + ); + + // Collect all events (exit, error and on log-lines), but let chromium keep spitting out + // logs as sometimes it's "bind" successfully for remote connections, but later emit + // a log indicative of an issue (for example, no default font found). + return merge(exit$, error$, log$).pipe( + takeUntil(timer(browserLaunchTimeToWait)), + reduce((acc, curr) => `${acc}${curr}\n`, ''), + tap(() => { + if (browserProcess && browserProcess.pid && !browserProcess.killed) { + browserProcess.kill('SIGKILL'); + logger.info(`Successfully sent 'SIGKILL' to browser process (PID: ${browserProcess.pid})`); + } + browserProcess.removeAllListeners(); + rl.removeAllListeners(); + rl.close(); + del(userDataDir, { force: true }).catch((error) => { + logger.error(`Error deleting user data directory at [${userDataDir}]!`); + logger.error(error); + }); + }), + catchError((error) => { + logger.error(error); + return of(error); + }) + ); +}; diff --git a/x-pack/plugins/reporting/server/browsers/install.ts b/x-pack/plugins/reporting/server/browsers/install.ts index 9eddbe5ef0498..35cc5b6d8b7c2 100644 --- a/x-pack/plugins/reporting/server/browsers/install.ts +++ b/x-pack/plugins/reporting/server/browsers/install.ts @@ -4,24 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ +import del from 'del'; import os from 'os'; import path from 'path'; -import del from 'del'; - import * as Rx from 'rxjs'; import { LevelLogger } from '../lib'; +import { paths } from './chromium/paths'; import { ensureBrowserDownloaded } from './download'; // @ts-ignore import { md5 } from './download/checksum'; // @ts-ignore import { extract } from './extract'; -import { paths } from './chromium/paths'; interface Package { platforms: string[]; architecture: string; } +/** + * Small helper util to resolve where chromium is installed + */ +export const getBinaryPath = ( + chromiumPath: string = path.resolve(__dirname, '../../chromium'), + platform: string = process.platform, + architecture: string = os.arch() +) => { + const pkg = paths.packages.find((p: Package) => { + return p.platforms.includes(platform) && p.architecture === architecture; + }); + + if (!pkg) { + // TODO: validate this + throw new Error(`Unsupported platform: ${platform}-${architecture}`); + } + + return path.join(chromiumPath, pkg.binaryRelativePath); +}; + /** * "install" a browser by type into installs path by extracting the downloaded * archive. If there is an error extracting the archive an `ExtractError` is thrown @@ -43,7 +62,7 @@ export function installBrowser( throw new Error(`Unsupported platform: ${platform}-${architecture}`); } - const binaryPath = path.join(chromiumPath, pkg.binaryRelativePath); + const binaryPath = getBinaryPath(chromiumPath, platform, architecture); const binaryChecksum = await md5(binaryPath).catch(() => ''); if (binaryChecksum !== pkg.binaryChecksum) { diff --git a/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts b/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts index c3d5b2cc60051..096d0bd428214 100644 --- a/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts +++ b/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts @@ -28,7 +28,7 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { if (!layoutParams || !layoutParams.dimensions) { throw new Error(`LayoutParams.Dimensions is undefined.`); } - const layout = new PreserveLayout(layoutParams.dimensions); + const layout = new PreserveLayout(layoutParams.dimensions, layoutParams.selectors); if (apmLayout) apmLayout.end(); const apmScreenshots = apmTrans?.startSpan('screenshots_pipeline', 'setup'); diff --git a/x-pack/plugins/reporting/server/lib/index.ts b/x-pack/plugins/reporting/server/lib/index.ts index f3a09cffbb104..9e5a3ca76126d 100644 --- a/x-pack/plugins/reporting/server/lib/index.ts +++ b/x-pack/plugins/reporting/server/lib/index.ts @@ -13,4 +13,3 @@ export { LevelLogger } from './level_logger'; export { statuses } from './statuses'; export { ReportingStore } from './store'; export { startTrace } from './trace'; -export { runValidations } from './validate'; diff --git a/x-pack/plugins/reporting/server/lib/layouts/index.ts b/x-pack/plugins/reporting/server/lib/layouts/index.ts index d46f088475222..507b7614072ea 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/index.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/index.ts @@ -54,6 +54,7 @@ export interface Size { export interface LayoutParams { id: string; dimensions: Size; + selectors?: LayoutSelectorDictionary; } interface LayoutSelectors { diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts index 9041055ddce2d..e8d182dac0b1d 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts @@ -25,12 +25,16 @@ export class PreserveLayout extends Layout { private readonly scaledHeight: number; private readonly scaledWidth: number; - constructor(size: Size) { + constructor(size: Size, layoutSelectors?: LayoutSelectorDictionary) { super(LayoutTypes.PRESERVE_LAYOUT); this.height = size.height; this.width = size.width; this.scaledHeight = size.height * ZOOM; this.scaledWidth = size.width * ZOOM; + + if (layoutSelectors) { + this.selectors = layoutSelectors; + } } public getCssOverridesPath() { diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index e6c4eb7346460..b87466ca289cf 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -7,8 +7,7 @@ import sinon from 'sinon'; import { ElasticsearchServiceSetup } from 'src/core/server'; import { ReportingConfig, ReportingCore } from '../..'; -import { createMockReportingCore } from '../../test_helpers'; -import { createMockLevelLogger } from '../../test_helpers/create_mock_levellogger'; +import { createMockReportingCore, createMockLevelLogger } from '../../test_helpers'; import { Report } from './report'; import { ReportingStore } from './store'; diff --git a/x-pack/plugins/reporting/server/lib/validate/index.ts b/x-pack/plugins/reporting/server/lib/validate/index.ts deleted file mode 100644 index d20df6b7315be..0000000000000 --- a/x-pack/plugins/reporting/server/lib/validate/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { ElasticsearchServiceSetup } from 'kibana/server'; -import { ReportingConfig } from '../../'; -import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; -import { LevelLogger } from '../'; -import { validateBrowser } from './validate_browser'; -import { validateMaxContentLength } from './validate_max_content_length'; - -export async function runValidations( - config: ReportingConfig, - elasticsearch: ElasticsearchServiceSetup, - browserFactory: HeadlessChromiumDriverFactory, - parentLogger: LevelLogger -) { - const logger = parentLogger.clone(['validations']); - try { - await Promise.all([ - validateBrowser(browserFactory, logger), - validateMaxContentLength(config, elasticsearch, logger), - ]); - logger.debug( - i18n.translate('xpack.reporting.selfCheck.ok', { - defaultMessage: `Reporting plugin self-check ok!`, - }) - ); - } catch (err) { - logger.error(err); - logger.warning( - i18n.translate('xpack.reporting.selfCheck.warning', { - defaultMessage: `Reporting plugin self-check generated a warning: {err}`, - values: { - err, - }, - }) - ); - } -} diff --git a/x-pack/plugins/reporting/server/lib/validate/validate_browser.ts b/x-pack/plugins/reporting/server/lib/validate/validate_browser.ts deleted file mode 100644 index d29aa522dad90..0000000000000 --- a/x-pack/plugins/reporting/server/lib/validate/validate_browser.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Browser } from 'puppeteer'; -import { BROWSER_TYPE } from '../../../common/constants'; -import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; -import { LevelLogger } from '../'; - -/* - * Validate the Reporting headless browser can launch, and that it can connect - * to the locally running Kibana instance. - */ -export const validateBrowser = async ( - browserFactory: HeadlessChromiumDriverFactory, - logger: LevelLogger -) => { - if (browserFactory.type === BROWSER_TYPE) { - return browserFactory.test(logger).then((browser: Browser | null) => { - if (browser && browser.close) { - browser.close(); - } else { - throw new Error('Could not close browser client handle!'); - } - }); - } -}; diff --git a/x-pack/plugins/reporting/server/lib/validate/validate_max_content_length.test.js b/x-pack/plugins/reporting/server/lib/validate/validate_max_content_length.test.js deleted file mode 100644 index f358021560cff..0000000000000 --- a/x-pack/plugins/reporting/server/lib/validate/validate_max_content_length.test.js +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import sinon from 'sinon'; -import { validateMaxContentLength } from './validate_max_content_length'; - -const FIVE_HUNDRED_MEGABYTES = 524288000; -const ONE_HUNDRED_MEGABYTES = 104857600; - -describe('Reporting: Validate Max Content Length', () => { - const elasticsearch = { - legacy: { - client: { - callAsInternalUser: () => ({ - defaults: { - http: { - max_content_length: '100mb', - }, - }, - }), - }, - }, - }; - - const logger = { - warning: sinon.spy(), - }; - - beforeEach(() => { - logger.warning.resetHistory(); - }); - - it('should log warning messages when reporting has a higher max-size than elasticsearch', async () => { - const config = { get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES) }; - const elasticsearch = { - legacy: { - client: { - callAsInternalUser: () => ({ - defaults: { - http: { - max_content_length: '100mb', - }, - }, - }), - }, - }, - }; - - await validateMaxContentLength(config, elasticsearch, logger); - - sinon.assert.calledWithMatch( - logger.warning, - `xpack.reporting.csv.maxSizeBytes (524288000) is higher` - ); - sinon.assert.calledWithMatch( - logger.warning, - `than ElasticSearch's http.max_content_length (104857600)` - ); - sinon.assert.calledWithMatch( - logger.warning, - 'Please set http.max_content_length in ElasticSearch to match' - ); - sinon.assert.calledWithMatch( - logger.warning, - 'or lower your xpack.reporting.csv.maxSizeBytes in Kibana' - ); - }); - - it('should do nothing when reporting has the same max-size as elasticsearch', async () => { - const config = { get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES) }; - - expect( - async () => await validateMaxContentLength(config, elasticsearch, logger.warning) - ).not.toThrow(); - sinon.assert.notCalled(logger.warning); - }); -}); diff --git a/x-pack/plugins/reporting/server/lib/validate/validate_max_content_length.ts b/x-pack/plugins/reporting/server/lib/validate/validate_max_content_length.ts deleted file mode 100644 index c38c6e5297854..0000000000000 --- a/x-pack/plugins/reporting/server/lib/validate/validate_max_content_length.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import numeral from '@elastic/numeral'; -import { ElasticsearchServiceSetup } from 'kibana/server'; -import { defaults, get } from 'lodash'; -import { ReportingConfig } from '../../'; -import { LevelLogger } from '../'; - -const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; -const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; - -export async function validateMaxContentLength( - config: ReportingConfig, - elasticsearch: ElasticsearchServiceSetup, - logger: LevelLogger -) { - const { callAsInternalUser } = elasticsearch.legacy.client; - - const elasticClusterSettingsResponse = await callAsInternalUser('cluster.getSettings', { - includeDefaults: true, - }); - const { persistent, transient, defaults: defaultSettings } = elasticClusterSettingsResponse; - const elasticClusterSettings = defaults({}, persistent, transient, defaultSettings); - - const elasticSearchMaxContent = get(elasticClusterSettings, 'http.max_content_length', '100mb'); - const elasticSearchMaxContentBytes = numeral().unformat(elasticSearchMaxContent.toUpperCase()); - const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes'); - - if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { - // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. - logger.warning( - `xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + - `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` - ); - } -} diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index 8c0e352aa06c5..af1ccfd592b96 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -11,7 +11,7 @@ import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from '../common/constants'; import { ReportingCore } from './'; import { initializeBrowserDriverFactory } from './browsers'; import { buildConfig, ReportingConfigType } from './config'; -import { createQueueFactory, LevelLogger, ReportingStore, runValidations } from './lib'; +import { createQueueFactory, LevelLogger, ReportingStore } from './lib'; import { registerRoutes } from './routes'; import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; @@ -105,7 +105,6 @@ export class ReportingPlugin setFieldFormats(plugins.data.fieldFormats); const { logger, reportingCore } = this; - const { elasticsearch } = reportingCore.getPluginSetupDeps(); // async background start (async () => { @@ -124,9 +123,6 @@ export class ReportingPlugin store, }); - // run self-check validations - runValidations(config, elasticsearch, browserDriverFactory, this.logger); - this.logger.debug('Start complete'); })().catch((e) => { this.logger.error(`Error in Reporting start, reporting may not function properly`); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts new file mode 100644 index 0000000000000..f92fbfc7013cf --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts @@ -0,0 +1,250 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UnwrapPromise } from '@kbn/utility-types'; +import { spawn } from 'child_process'; +import { createInterface } from 'readline'; +import { setupServer } from 'src/core/server/test_utils'; +import supertest from 'supertest'; +import { ReportingCore } from '../..'; +import { createMockLevelLogger, createMockReportingCore } from '../../test_helpers'; +import { registerDiagnoseBrowser } from './browser'; + +jest.mock('child_process'); +jest.mock('readline'); + +type SetupServerReturn = UnwrapPromise>; + +const devtoolMessage = 'DevTools listening on (ws://localhost:4000)'; +const fontNotFoundMessage = 'Could not find the default font'; + +describe('POST /diagnose/browser', () => { + jest.setTimeout(6000); + const reportingSymbol = Symbol('reporting'); + const mockLogger = createMockLevelLogger(); + + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let core: ReportingCore; + const mockedSpawn: any = spawn; + const mockedCreateInterface: any = createInterface; + + const config = { + get: jest.fn().mockImplementation(() => ({})), + kbnConfig: { get: jest.fn() }, + }; + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer(reportingSymbol)); + httpSetup.registerRouteHandlerContext(reportingSymbol, 'reporting', () => ({})); + + const mockSetupDeps = ({ + elasticsearch: { + legacy: { client: { callAsInternalUser: jest.fn() } }, + }, + router: httpSetup.createRouter(''), + } as unknown) as any; + + core = await createMockReportingCore(config, mockSetupDeps); + + mockedSpawn.mockImplementation(() => ({ + removeAllListeners: jest.fn(), + kill: jest.fn(), + pid: 123, + stderr: 'stderr', + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + + mockedCreateInterface.mockImplementation(() => ({ + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + removeAllListeners: jest.fn(), + close: jest.fn(), + })); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('returns a 200 when successful', async () => { + registerDiagnoseBrowser(core, mockLogger); + + await server.start(); + + mockedCreateInterface.mockImplementation(() => ({ + addEventListener: (e: string, cb: any) => setTimeout(() => cb(devtoolMessage), 0), + removeEventListener: jest.fn(), + removeAllListeners: jest.fn(), + close: jest.fn(), + })); + + return supertest(httpSetup.server.listener) + .post('/api/reporting/diagnose/browser') + .expect(200) + .then(({ body }) => { + expect(body.success).toEqual(true); + expect(body.help).toEqual([]); + }); + }); + + it('returns logs when browser crashes + helpful links', async () => { + const logs = `Could not find the default font`; + registerDiagnoseBrowser(core, mockLogger); + + await server.start(); + + mockedCreateInterface.mockImplementation(() => ({ + addEventListener: (e: string, cb: any) => setTimeout(() => cb(logs), 0), + removeEventListener: jest.fn(), + removeAllListeners: jest.fn(), + close: jest.fn(), + })); + + mockedSpawn.mockImplementation(() => ({ + removeAllListeners: jest.fn(), + kill: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + + return supertest(httpSetup.server.listener) + .post('/api/reporting/diagnose/browser') + .expect(200) + .then(({ body }) => { + expect(body).toMatchInlineSnapshot(` + Object { + "help": Array [ + "The browser couldn't locate a default font. Please see https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies to fix this issue.", + ], + "logs": "Could not find the default font + ", + "success": false, + } + `); + }); + }); + + it('logs a message when the browser starts, but then has problems later', async () => { + registerDiagnoseBrowser(core, mockLogger); + + await server.start(); + + mockedCreateInterface.mockImplementation(() => ({ + addEventListener: (e: string, cb: any) => { + setTimeout(() => cb(devtoolMessage), 0); + setTimeout(() => cb(fontNotFoundMessage), 0); + }, + removeEventListener: jest.fn(), + removeAllListeners: jest.fn(), + close: jest.fn(), + })); + + mockedSpawn.mockImplementation(() => ({ + removeAllListeners: jest.fn(), + kill: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + + return supertest(httpSetup.server.listener) + .post('/api/reporting/diagnose/browser') + .expect(200) + .then(({ body }) => { + expect(body).toMatchInlineSnapshot(` + Object { + "help": Array [ + "The browser couldn't locate a default font. Please see https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies to fix this issue.", + ], + "logs": "DevTools listening on (ws://localhost:4000) + Could not find the default font + ", + "success": false, + } + `); + }); + }); + + it('logs a message when the browser starts, but then crashes', async () => { + registerDiagnoseBrowser(core, mockLogger); + + await server.start(); + + mockedCreateInterface.mockImplementation(() => ({ + addEventListener: (e: string, cb: any) => { + setTimeout(() => cb(fontNotFoundMessage), 0); + }, + removeEventListener: jest.fn(), + removeAllListeners: jest.fn(), + close: jest.fn(), + })); + + mockedSpawn.mockImplementation(() => ({ + removeAllListeners: jest.fn(), + kill: jest.fn(), + addEventListener: (e: string, cb: any) => { + if (e === 'exit') { + setTimeout(() => cb(), 5); + } + }, + removeEventListener: jest.fn(), + })); + + return supertest(httpSetup.server.listener) + .post('/api/reporting/diagnose/browser') + .expect(200) + .then(({ body }) => { + expect(body).toMatchInlineSnapshot(` + Object { + "help": Array [ + "The browser couldn't locate a default font. Please see https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies to fix this issue.", + ], + "logs": "Could not find the default font + Browser exited abnormally during startup + ", + "success": false, + } + `); + }); + }); + + it('cleans up process and subscribers', async () => { + registerDiagnoseBrowser(core, mockLogger); + + await server.start(); + const killMock = jest.fn(); + const spawnListenersMock = jest.fn(); + const createInterfaceListenersMock = jest.fn(); + const createInterfaceCloseMock = jest.fn(); + + mockedSpawn.mockImplementation(() => ({ + removeAllListeners: spawnListenersMock, + kill: killMock, + pid: 123, + stderr: 'stderr', + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + + mockedCreateInterface.mockImplementation(() => ({ + addEventListener: (e: string, cb: any) => setTimeout(() => cb(devtoolMessage), 0), + removeEventListener: jest.fn(), + removeAllListeners: createInterfaceListenersMock, + close: createInterfaceCloseMock, + })); + + return supertest(httpSetup.server.listener) + .post('/api/reporting/diagnose/browser') + .expect(200) + .then(() => { + expect(killMock.mock.calls.length).toBe(1); + expect(spawnListenersMock.mock.calls.length).toBe(1); + expect(createInterfaceListenersMock.mock.calls.length).toBe(1); + expect(createInterfaceCloseMock.mock.calls.length).toBe(1); + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts new file mode 100644 index 0000000000000..24b85220defb4 --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ReportingCore } from '../..'; +import { API_DIAGNOSE_URL } from '../../../common/constants'; +import { browserStartLogs } from '../../browsers/chromium/driver_factory/start_logs'; +import { LevelLogger as Logger } from '../../lib'; +import { DiagnosticResponse } from '../../types'; +import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing'; + +const logsToHelpMap = { + 'error while loading shared libraries': i18n.translate( + 'xpack.reporting.diagnostic.browserMissingDependency', + { + defaultMessage: `The browser couldn't start properly due to missing system dependencies. Please see {url}`, + values: { + url: + 'https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies', + }, + } + ), + + 'Could not find the default font': i18n.translate( + 'xpack.reporting.diagnostic.browserMissingFonts', + { + defaultMessage: `The browser couldn't locate a default font. Please see {url} to fix this issue.`, + values: { + url: + 'https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies', + }, + } + ), + + 'No usable sandbox': i18n.translate('xpack.reporting.diagnostic.noUsableSandbox', { + defaultMessage: `Unable to use Chromium sandbox. This can be disabled at your own risk with 'xpack.reporting.capture.browser.chromium.disableSandbox'. Please see {url}`, + values: { + url: + 'https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-sandbox-dependency', + }, + }), +}; + +export const registerDiagnoseBrowser = (reporting: ReportingCore, logger: Logger) => { + const { router } = reporting.getPluginSetupDeps(); + const userHandler = authorizedUserPreRoutingFactory(reporting); + + router.post( + { + path: `${API_DIAGNOSE_URL}/browser`, + validate: {}, + }, + userHandler(async (user, context, req, res) => { + const logs = await browserStartLogs(reporting, logger).toPromise(); + const knownIssues = Object.keys(logsToHelpMap) as Array; + + const boundSuccessfully = logs.includes(`DevTools listening on`); + const help = knownIssues.reduce((helpTexts: string[], knownIssue) => { + const helpText = logsToHelpMap[knownIssue]; + if (logs.includes(knownIssue)) { + helpTexts.push(helpText); + } + return helpTexts; + }, []); + + const response: DiagnosticResponse = { + success: boundSuccessfully && !help.length, + help, + logs, + }; + + return res.ok({ body: response }); + }) + ); +}; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts new file mode 100644 index 0000000000000..624397246656d --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/diagnostic/config.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UnwrapPromise } from '@kbn/utility-types'; +import { setupServer } from 'src/core/server/test_utils'; +import supertest from 'supertest'; +import { ReportingCore } from '../..'; +import { createMockReportingCore, createMockLevelLogger } from '../../test_helpers'; +import { registerDiagnoseConfig } from './config'; + +type SetupServerReturn = UnwrapPromise>; + +describe('POST /diagnose/config', () => { + const reportingSymbol = Symbol('reporting'); + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let core: ReportingCore; + let mockSetupDeps: any; + let config: any; + + const mockLogger = createMockLevelLogger(); + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer(reportingSymbol)); + httpSetup.registerRouteHandlerContext(reportingSymbol, 'reporting', () => ({})); + + mockSetupDeps = ({ + elasticsearch: { + legacy: { client: { callAsInternalUser: jest.fn() } }, + }, + router: httpSetup.createRouter(''), + } as unknown) as any; + + config = { + get: jest.fn(), + kbnConfig: { get: jest.fn() }, + }; + + core = await createMockReportingCore(config, mockSetupDeps); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('returns a 200 by default when configured properly', async () => { + mockSetupDeps.elasticsearch.legacy.client.callAsInternalUser.mockImplementation(() => + Promise.resolve({ + defaults: { + http: { + max_content_length: '100mb', + }, + }, + }) + ); + registerDiagnoseConfig(core, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post('/api/reporting/diagnose/config') + .expect(200) + .then(({ body }) => { + expect(body).toMatchInlineSnapshot(` + Object { + "help": Array [], + "logs": "", + "success": true, + } + `); + }); + }); + + it('returns a 200 with help text when not configured properly', async () => { + config.get.mockImplementation(() => 10485760); + mockSetupDeps.elasticsearch.legacy.client.callAsInternalUser.mockImplementation(() => + Promise.resolve({ + defaults: { + http: { + max_content_length: '5mb', + }, + }, + }) + ); + registerDiagnoseConfig(core, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post('/api/reporting/diagnose/config') + .expect(200) + .then(({ body }) => { + expect(body).toMatchInlineSnapshot(` + Object { + "help": Array [ + "xpack.reporting.csv.maxSizeBytes (10485760) is higher than ElasticSearch's http.max_content_length (5242880). Please set http.max_content_length in ElasticSearch to match, or lower your xpack.reporting.csv.maxSizeBytes in Kibana.", + ], + "logs": "xpack.reporting.csv.maxSizeBytes (10485760) is higher than ElasticSearch's http.max_content_length (5242880). Please set http.max_content_length in ElasticSearch to match, or lower your xpack.reporting.csv.maxSizeBytes in Kibana.", + "success": false, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/config.ts b/x-pack/plugins/reporting/server/routes/diagnostic/config.ts new file mode 100644 index 0000000000000..198ba63e2614d --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/diagnostic/config.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import numeral from '@elastic/numeral'; +import { defaults, get } from 'lodash'; +import { ReportingCore } from '../..'; +import { API_DIAGNOSE_URL } from '../../../common/constants'; +import { LevelLogger as Logger } from '../../lib'; +import { DiagnosticResponse } from '../../types'; +import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing'; + +const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; +const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; + +export const registerDiagnoseConfig = (reporting: ReportingCore, logger: Logger) => { + const setupDeps = reporting.getPluginSetupDeps(); + const userHandler = authorizedUserPreRoutingFactory(reporting); + const { router, elasticsearch } = setupDeps; + + router.post( + { + path: `${API_DIAGNOSE_URL}/config`, + validate: {}, + }, + userHandler(async (user, context, req, res) => { + const warnings = []; + const { callAsInternalUser } = elasticsearch.legacy.client; + const config = reporting.getConfig(); + + const elasticClusterSettingsResponse = await callAsInternalUser('cluster.getSettings', { + includeDefaults: true, + }); + const { persistent, transient, defaults: defaultSettings } = elasticClusterSettingsResponse; + const elasticClusterSettings = defaults({}, persistent, transient, defaultSettings); + + const elasticSearchMaxContent = get( + elasticClusterSettings, + 'http.max_content_length', + '100mb' + ); + const elasticSearchMaxContentBytes = numeral().unformat( + elasticSearchMaxContent.toUpperCase() + ); + const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes'); + + if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { + const maxContentSizeWarning = i18n.translate( + 'xpack.reporting.diagnostic.configSizeMismatch', + { + defaultMessage: + `xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} ({kibanaMaxContentBytes}) is higher than ElasticSearch's {ES_MAX_SIZE_BYTES_PATH} ({elasticSearchMaxContentBytes}). ` + + `Please set {ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} in Kibana.`, + values: { + kibanaMaxContentBytes, + elasticSearchMaxContentBytes, + KIBANA_MAX_SIZE_BYTES_PATH, + ES_MAX_SIZE_BYTES_PATH, + }, + } + ); + warnings.push(maxContentSizeWarning); + } + + if (warnings.length) { + warnings.forEach((warn) => logger.warn(warn)); + } + + const body: DiagnosticResponse = { + help: warnings, + success: !warnings.length, + logs: warnings.join('\n'), + }; + + return res.ok({ body }); + }) + ); +}; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/index.ts b/x-pack/plugins/reporting/server/routes/diagnostic/index.ts new file mode 100644 index 0000000000000..895dee32614f1 --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/diagnostic/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerDiagnoseBrowser } from './browser'; +import { registerDiagnoseConfig } from './config'; +import { registerDiagnoseScreenshot } from './screenshot'; +import { LevelLogger as Logger } from '../../lib'; +import { ReportingCore } from '../../core'; + +export const registerDiagnosticRoutes = (reporting: ReportingCore, logger: Logger) => { + registerDiagnoseBrowser(reporting, logger); + registerDiagnoseConfig(reporting, logger); + registerDiagnoseScreenshot(reporting, logger); +}; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts new file mode 100644 index 0000000000000..ec4ab0446ae5f --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UnwrapPromise } from '@kbn/utility-types'; +import { setupServer } from 'src/core/server/test_utils'; +import supertest from 'supertest'; +import { ReportingCore } from '../..'; +import { createMockReportingCore, createMockLevelLogger } from '../../test_helpers'; +import { registerDiagnoseScreenshot } from './screenshot'; + +jest.mock('../../export_types/png/lib/generate_png'); + +import { generatePngObservableFactory } from '../../export_types/png/lib/generate_png'; + +type SetupServerReturn = UnwrapPromise>; + +describe('POST /diagnose/screenshot', () => { + const reportingSymbol = Symbol('reporting'); + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let core: ReportingCore; + + const setScreenshotResponse = (resp: object | Error) => { + const generateMock = Promise.resolve(() => ({ + pipe: () => ({ + toPromise: () => (resp instanceof Error ? Promise.reject(resp) : Promise.resolve(resp)), + }), + })); + (generatePngObservableFactory as any).mockResolvedValue(generateMock); + }; + + const config = { + get: jest.fn(), + kbnConfig: { get: jest.fn() }, + }; + const mockLogger = createMockLevelLogger(); + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer(reportingSymbol)); + httpSetup.registerRouteHandlerContext(reportingSymbol, 'reporting', () => ({})); + + const mockSetupDeps = ({ + elasticsearch: { + legacy: { client: { callAsInternalUser: jest.fn() } }, + }, + router: httpSetup.createRouter(''), + } as unknown) as any; + + core = await createMockReportingCore(config, mockSetupDeps); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('returns a 200 by default', async () => { + registerDiagnoseScreenshot(core, mockLogger); + setScreenshotResponse({ warnings: [] }); + await server.start(); + + await supertest(httpSetup.server.listener) + .post('/api/reporting/diagnose/screenshot') + .expect(200) + .then(({ body }) => { + expect(body).toMatchInlineSnapshot(` + Object { + "help": Array [], + "logs": "", + "success": true, + } + `); + }); + }); + + it('returns a 200 when it fails and sets success to false', async () => { + registerDiagnoseScreenshot(core, mockLogger); + setScreenshotResponse({ warnings: [`Timeout waiting for .dank to load`] }); + await server.start(); + + await supertest(httpSetup.server.listener) + .post('/api/reporting/diagnose/screenshot') + .expect(200) + .then(({ body }) => { + expect(body).toMatchInlineSnapshot(` + Object { + "help": Array [], + "logs": Array [ + "Timeout waiting for .dank to load", + ], + "success": false, + } + `); + }); + }); + + it('catches errors and returns a well formed response', async () => { + registerDiagnoseScreenshot(core, mockLogger); + setScreenshotResponse(new Error('Failure to start chromium!')); + await server.start(); + + await supertest(httpSetup.server.listener) + .post('/api/reporting/diagnose/screenshot') + .expect(200) + .then(({ body }) => { + expect(body.help).toContain(`We couldn't screenshot your Kibana install.`); + expect(body.logs).toContain(`Failure to start chromium!`); + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts new file mode 100644 index 0000000000000..7e07779b5fd37 --- /dev/null +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ReportingCore } from '../..'; +import { API_DIAGNOSE_URL } from '../../../common/constants'; +import { omitBlacklistedHeaders } from '../../export_types/common'; +import { getAbsoluteUrlFactory } from '../../export_types/common/get_absolute_url'; +import { generatePngObservableFactory } from '../../export_types/png/lib/generate_png'; +import { LevelLogger as Logger } from '../../lib'; +import { DiagnosticResponse } from '../../types'; +import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing'; + +export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Logger) => { + const setupDeps = reporting.getPluginSetupDeps(); + const userHandler = authorizedUserPreRoutingFactory(reporting); + const { router } = setupDeps; + + router.post( + { + path: `${API_DIAGNOSE_URL}/screenshot`, + validate: {}, + }, + userHandler(async (user, context, req, res) => { + const generatePngObservable = await generatePngObservableFactory(reporting); + const config = reporting.getConfig(); + const decryptedHeaders = req.headers as Record; + const [basePath, protocol, hostname, port] = [ + config.kbnConfig.get('server', 'basePath'), + config.get('kibanaServer', 'protocol'), + config.get('kibanaServer', 'hostname'), + config.get('kibanaServer', 'port'), + ] as string[]; + + const getAbsoluteUrl = getAbsoluteUrlFactory({ + defaultBasePath: basePath, + protocol, + hostname, + port, + }); + + const hashUrl = getAbsoluteUrl({ + basePath, + path: '/', + hash: '', + search: '', + }); + + // Hack the layout to make the base/login page work + const layout = { + id: 'png', + dimensions: { + width: 1440, + height: 2024, + }, + selectors: { + screenshot: '.application', + renderComplete: '.application', + itemsCountAttribute: 'data-test-subj="kibanaChrome"', + timefilterDurationAttribute: 'data-test-subj="kibanaChrome"', + }, + }; + + const headers = { + headers: omitBlacklistedHeaders({ + job: null, + decryptedHeaders, + }), + conditions: { + hostname, + port: +port, + basePath, + protocol, + }, + }; + + return generatePngObservable(logger, hashUrl, 'America/Los_Angeles', headers, layout) + .pipe() + .toPromise() + .then((screenshot) => { + if (screenshot.warnings.length) { + return res.ok({ + body: { + success: false, + help: [], + logs: screenshot.warnings, + }, + }); + } + return res.ok({ + body: { + success: true, + help: [], + logs: '', + } as DiagnosticResponse, + }); + }) + .catch((error) => + res.ok({ + body: { + success: false, + help: [ + i18n.translate('xpack.reporting.diagnostic.screenshotFailureMessage', { + defaultMessage: `We couldn't screenshot your Kibana install.`, + }), + ], + logs: error.message, + } as DiagnosticResponse, + }) + ); + }) + ); +}; diff --git a/x-pack/plugins/reporting/server/routes/generation.test.ts b/x-pack/plugins/reporting/server/routes/generation.test.ts index 0db0073149e57..dd905223a81d5 100644 --- a/x-pack/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/plugins/reporting/server/routes/generation.test.ts @@ -11,8 +11,7 @@ import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { ReportingCore } from '..'; import { ExportTypesRegistry } from '../lib/export_types_registry'; -import { createMockReportingCore } from '../test_helpers'; -import { createMockLevelLogger } from '../test_helpers/create_mock_levellogger'; +import { createMockReportingCore, createMockLevelLogger } from '../test_helpers'; import { registerJobGenerationRoutes } from './generation'; type SetupServerReturn = UnwrapPromise>; diff --git a/x-pack/plugins/reporting/server/routes/index.ts b/x-pack/plugins/reporting/server/routes/index.ts index 005d82086665c..11ad4cc9d4eb8 100644 --- a/x-pack/plugins/reporting/server/routes/index.ts +++ b/x-pack/plugins/reporting/server/routes/index.ts @@ -8,8 +8,10 @@ import { LevelLogger as Logger } from '../lib'; import { registerJobGenerationRoutes } from './generation'; import { registerJobInfoRoutes } from './jobs'; import { ReportingCore } from '../core'; +import { registerDiagnosticRoutes } from './diagnostic'; export function registerRoutes(reporting: ReportingCore, logger: Logger) { registerJobGenerationRoutes(reporting, logger); registerJobInfoRoutes(reporting); + registerDiagnosticRoutes(reporting, logger); } diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index c508ee6974ca0..d1ebb4d59e631 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -8,7 +8,6 @@ jest.mock('../routes'); jest.mock('../usage'); jest.mock('../browsers'); jest.mock('../lib/create_queue'); -jest.mock('../lib/validate'); import * as Rx from 'rxjs'; import { ReportingConfig, ReportingCore } from '../'; diff --git a/x-pack/plugins/reporting/server/test_helpers/index.ts b/x-pack/plugins/reporting/server/test_helpers/index.ts index b37b447dc05a9..2d5ef9fdd768d 100644 --- a/x-pack/plugins/reporting/server/test_helpers/index.ts +++ b/x-pack/plugins/reporting/server/test_helpers/index.ts @@ -8,3 +8,4 @@ export { createMockServer } from './create_mock_server'; export { createMockReportingCore, createMockConfigSchema } from './create_mock_reportingplugin'; export { createMockBrowserDriverFactory } from './create_mock_browserdriverfactory'; export { createMockLayoutInstance } from './create_mock_layoutinstance'; +export { createMockLevelLogger } from './create_mock_levellogger'; diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index 10519842d9dec..bb2d5368cd181 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -160,3 +160,9 @@ export interface ExportTypeDefinition< runTaskFnFactory: RunTaskFnFactory; validLicenses: string[]; } + +export interface DiagnosticResponse { + help: string[]; + success: boolean; + logs: string; +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f753e0ec87064..54c92d323fcff 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -14079,8 +14079,6 @@ "xpack.reporting.screencapture.waitingForRenderComplete": "レンダリングの完了を待っています", "xpack.reporting.screencapture.waitingForRenderedElements": "レンダリングされた {itemsCount} 個の要素が DOM に入るのを待っています", "xpack.reporting.screenCapturePanelContent.optimizeForPrintingLabel": "印刷用に最適化", - "xpack.reporting.selfCheck.ok": "レポートプラグイン自己チェックOK!", - "xpack.reporting.selfCheck.warning": "レポートプラグイン自己チェックで警告が発生しました: {err}", "xpack.reporting.serverConfig.autoSet.sandboxDisabled": "Chromiumサンドボックスは保護が強化されていますが、{osName} OSではサポートされていません。自動的に'{configKey}: true'を設定しています。", "xpack.reporting.serverConfig.autoSet.sandboxEnabled": "Chromiumサンドボックスは保護が強化され、{osName} OSでサポートされています。自動的にChromiumサンドボックスを有効にしています。", "xpack.reporting.serverConfig.invalidServerHostname": "Kibana構成で「server.host:\"0\"」が見つかりました。これはReportingと互換性がありません。レポートが動作するように、「{configKey}:0.0.0.0」が自動的に構成になります。設定を「server.host:0.0.0.0」に変更するか、kibana.ymlに「{configKey}:0.0.0.0'」を追加して、このメッセージが表示されないようにすることができます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8841db0be8d95..df721cb624662 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -14088,8 +14088,6 @@ "xpack.reporting.screencapture.waitingForRenderComplete": "正在等候渲染完成", "xpack.reporting.screencapture.waitingForRenderedElements": "正在等候 {itemsCount} 个已渲染元素进入 DOM", "xpack.reporting.screenCapturePanelContent.optimizeForPrintingLabel": "打印优化", - "xpack.reporting.selfCheck.ok": "Reporting 插件自检正常!", - "xpack.reporting.selfCheck.warning": "Reporting 插件自检生成警告:{err}", "xpack.reporting.serverConfig.autoSet.sandboxDisabled": "Chromium 沙盒提供附加保护层,但不受 {osName} OS 支持。自动设置“{configKey}: true”。", "xpack.reporting.serverConfig.autoSet.sandboxEnabled": "Chromium 沙盒提供附加保护层,受 {osName} OS 支持。自动启用 Chromium 沙盒。", "xpack.reporting.serverConfig.invalidServerHostname": "在 Kibana 配置中找到“server.host:\"0\"”。其不与 Reporting 兼容。要使 Reporting 运行,“{configKey}:0.0.0.0”将自动添加到配置中。可以将该设置更改为“server.host:0.0.0.0”或在 kibana.yml 中添加“{configKey}:0.0.0.0”,以阻止此消息。", From 2965f3e18375ff202e1877e86c53d20eb8163611 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 9 Sep 2020 19:48:07 -0400 Subject: [PATCH 19/25] Skip checking for the reserved realm (#76687) Co-authored-by: Elastic Machine --- .../apis/security/basic_login.js | 10 ++-------- .../apis/security/kerberos_login.ts | 2 +- .../apis/login_selector.ts | 19 +++++++------------ .../apis/authorization_code_flow/oidc_auth.ts | 2 +- .../apis/security/pki_auth.ts | 2 +- .../apis/security/saml_login.ts | 2 +- 6 files changed, 13 insertions(+), 24 deletions(-) diff --git a/x-pack/test/api_integration/apis/security/basic_login.js b/x-pack/test/api_integration/apis/security/basic_login.js index 4b39b1bf32d5b..43ef8e6b81eac 100644 --- a/x-pack/test/api_integration/apis/security/basic_login.js +++ b/x-pack/test/api_integration/apis/security/basic_login.js @@ -148,11 +148,8 @@ export default function ({ getService }) { ]); expect(apiResponse.body.username).to.be(validUsername); expect(apiResponse.body.authentication_provider).to.eql('__http__'); - expect(apiResponse.body.authentication_realm).to.eql({ - name: 'reserved', - type: 'reserved', - }); expect(apiResponse.body.authentication_type).to.be('realm'); + // Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud }); describe('with session cookie', () => { @@ -197,11 +194,8 @@ export default function ({ getService }) { ]); expect(apiResponse.body.username).to.be(validUsername); expect(apiResponse.body.authentication_provider).to.eql('basic'); - expect(apiResponse.body.authentication_realm).to.eql({ - name: 'reserved', - type: 'reserved', - }); expect(apiResponse.body.authentication_type).to.be('realm'); + // Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud }); it('should extend cookie on every successful non-system API call', async () => { diff --git a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts index 1f4428e198539..459dc4739897c 100644 --- a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts +++ b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts @@ -79,9 +79,9 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); expect(user.username).to.eql(username); - expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' }); expect(user.authentication_provider).to.eql('basic'); expect(user.authentication_type).to.eql('realm'); + // Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud }); describe('initiating SPNEGO', () => { diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/login_selector_api_integration/apis/login_selector.ts index 7eb1f07d67506..44582355cf890 100644 --- a/x-pack/test/login_selector_api_integration/apis/login_selector.ts +++ b/x-pack/test/login_selector_api_integration/apis/login_selector.ts @@ -36,7 +36,7 @@ export default function ({ getService }: FtrProviderContext) { sessionCookie: Cookie, username: string, providerName: string, - authenticationRealm: { name: string; type: string }, + authenticationRealm: { name: string; type: string } | null, authenticationType: string ) { expect(sessionCookie.key).to.be('sid'); @@ -67,7 +67,9 @@ export default function ({ getService }: FtrProviderContext) { expect(apiResponse.body.username).to.be(username); expect(apiResponse.body.authentication_provider).to.be(providerName); - expect(apiResponse.body.authentication_realm).to.eql(authenticationRealm); + if (authenticationRealm) { + expect(apiResponse.body.authentication_realm).to.eql(authenticationRealm); + } expect(apiResponse.body.authentication_type).to.be(authenticationType); } @@ -228,16 +230,9 @@ export default function ({ getService }: FtrProviderContext) { const basicSessionCookie = request.cookie( basicAuthenticationResponse.headers['set-cookie'][0] )!; - await checkSessionCookie( - basicSessionCookie, - 'elastic', - 'basic1', - { - name: 'reserved', - type: 'reserved', - }, - 'realm' - ); + // Skip auth provider check since this comes from the reserved realm, + // which is not available when running on ESS + await checkSessionCookie(basicSessionCookie, 'elastic', 'basic1', null, 'realm'); const authenticationResponse = await supertest .post('/api/security/saml/callback') diff --git a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts index 0a230ac84d991..c2335cf04504f 100644 --- a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts +++ b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts @@ -43,9 +43,9 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); expect(user.username).to.eql(username); - expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' }); expect(user.authentication_provider).to.eql('basic'); expect(user.authentication_type).to.be('realm'); + // Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud }); describe('initiating handshake', () => { diff --git a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts index 2f6b088ab7190..0559e9e96fe3f 100644 --- a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts +++ b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts @@ -93,8 +93,8 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); expect(user.username).to.eql(username); - expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' }); expect(user.authentication_provider).to.eql('basic'); + // Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud }); it('should properly set cookie and authenticate user', async () => { diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/saml_api_integration/apis/security/saml_login.ts index 501e1e5f2c203..2da7c92cd07b6 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.ts @@ -93,9 +93,9 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); expect(user.username).to.eql(username); - expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' }); expect(user.authentication_provider).to.eql('basic'); expect(user.authentication_type).to.be('realm'); + // Do not assert on the `authentication_realm`, as the value differes for on-prem vs cloud }); describe('initiating handshake', () => { From cc590b67b99d943e378daa48ddab28eb9fd6e08f Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 9 Sep 2020 19:52:47 -0400 Subject: [PATCH 20/25] Adds lens as a readable saved object for read-only dashboard users (#77067) --- .../features/server/__snapshots__/oss_features.test.ts.snap | 1 + x-pack/plugins/features/server/oss_features.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index e4014cf49778c..63a59d59d6d07 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -111,6 +111,7 @@ Array [ "visualization", "timelion-sheet", "canvas-workpad", + "lens", "map", "dashboard", "query", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index e37c7491de5dc..4122c590e74b1 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -172,6 +172,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS 'visualization', 'timelion-sheet', 'canvas-workpad', + 'lens', 'map', 'dashboard', 'query', From a19484abe0e3c28e7db58dfeb52516031b06c8cf Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Wed, 9 Sep 2020 18:08:48 -0600 Subject: [PATCH 21/25] Add plugin status API - take 2 (#76732) --- ...server.statusservicesetup.dependencies_.md | 13 + ...erver.statusservicesetup.derivedstatus_.md | 20 ++ ...a-plugin-core-server.statusservicesetup.md | 63 ++++ ...ugin-core-server.statusservicesetup.set.md | 28 ++ rfcs/text/0010_service_status.md | 2 +- src/core/server/legacy/legacy_service.ts | 11 + src/core/server/plugins/plugin_context.ts | 3 + .../server/plugins/plugins_system.test.ts | 30 +- src/core/server/plugins/plugins_system.ts | 21 +- src/core/server/plugins/types.ts | 6 + .../migrations/kibana/kibana_migrator.ts | 12 +- src/core/server/server.api.md | 13 +- src/core/server/server.test.ts | 4 +- src/core/server/server.ts | 8 +- .../server/status/get_summary_status.test.ts | 44 ++- src/core/server/status/get_summary_status.ts | 70 ++-- src/core/server/status/plugins_status.test.ts | 338 ++++++++++++++++++ src/core/server/status/plugins_status.ts | 98 +++++ src/core/server/status/status_service.mock.ts | 8 + src/core/server/status/status_service.test.ts | 75 ++++ src/core/server/status/status_service.ts | 40 ++- src/core/server/status/types.ts | 91 ++++- 22 files changed, 929 insertions(+), 69 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.statusservicesetup.dependencies_.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.statusservicesetup.derivedstatus_.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md create mode 100644 src/core/server/status/plugins_status.test.ts create mode 100644 src/core/server/status/plugins_status.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.dependencies_.md b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.dependencies_.md new file mode 100644 index 0000000000000..7475f0e3a4c1c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.dependencies_.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) > [dependencies$](./kibana-plugin-core-server.statusservicesetup.dependencies_.md) + +## StatusServiceSetup.dependencies$ property + +Current status for all plugins this plugin depends on. Each key of the `Record` is a plugin id. + +Signature: + +```typescript +dependencies$: Observable>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.derivedstatus_.md b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.derivedstatus_.md new file mode 100644 index 0000000000000..6c65e44270a06 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.derivedstatus_.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) > [derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md) + +## StatusServiceSetup.derivedStatus$ property + +The status of this plugin as derived from its dependencies. + +Signature: + +```typescript +derivedStatus$: Observable; +``` + +## Remarks + +By default, plugins inherit this derived status from their dependencies. Calling overrides this default status. + +This may emit multliple times for a single status change event as propagates through the dependency tree + diff --git a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.md index 3d3b73ccda25f..ba0645be4d26c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.md @@ -12,10 +12,73 @@ API for accessing status of Core and this plugin's dependencies as well as for c export interface StatusServiceSetup ``` +## Remarks + +By default, a plugin inherits it's current status from the most severe status level of any Core services and any plugins that it depends on. This default status is available on the API. + +Plugins may customize their status calculation by calling the API with an Observable. Within this Observable, a plugin may choose to only depend on the status of some of its dependencies, to ignore severe status levels of particular Core services they are not concerned with, or to make its status dependent on other external services. + +## Example 1 + +Customize a plugin's status to only depend on the status of SavedObjects: + +```ts +core.status.set( + core.status.core$.pipe( +. map((coreStatus) => { + return coreStatus.savedObjects; + }) ; + ); +); + +``` + +## Example 2 + +Customize a plugin's status to include an external service: + +```ts +const externalStatus$ = interval(1000).pipe( + switchMap(async () => { + const resp = await fetch(`https://myexternaldep.com/_healthz`); + const body = await resp.json(); + if (body.ok) { + return of({ level: ServiceStatusLevels.available, summary: 'External Service is up'}); + } else { + return of({ level: ServiceStatusLevels.available, summary: 'External Service is unavailable'}); + } + }), + catchError((error) => { + of({ level: ServiceStatusLevels.unavailable, summary: `External Service is down`, meta: { error }}) + }) +); + +core.status.set( + combineLatest([core.status.derivedStatus$, externalStatus$]).pipe( + map(([derivedStatus, externalStatus]) => { + if (externalStatus.level > derivedStatus) { + return externalStatus; + } else { + return derivedStatus; + } + }) + ) +); + +``` + ## Properties | Property | Type | Description | | --- | --- | --- | | [core$](./kibana-plugin-core-server.statusservicesetup.core_.md) | Observable<CoreStatus> | Current status for all Core services. | +| [dependencies$](./kibana-plugin-core-server.statusservicesetup.dependencies_.md) | Observable<Record<string, ServiceStatus>> | Current status for all plugins this plugin depends on. Each key of the Record is a plugin id. | +| [derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md) | Observable<ServiceStatus> | The status of this plugin as derived from its dependencies. | | [overall$](./kibana-plugin-core-server.statusservicesetup.overall_.md) | Observable<ServiceStatus> | Overall system status for all of Kibana. | +## Methods + +| Method | Description | +| --- | --- | +| [set(status$)](./kibana-plugin-core-server.statusservicesetup.set.md) | Allows a plugin to specify a custom status dependent on its own criteria. Completely overrides the default inherited status. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md new file mode 100644 index 0000000000000..143cd397c40ae --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.set.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) > [set](./kibana-plugin-core-server.statusservicesetup.set.md) + +## StatusServiceSetup.set() method + +Allows a plugin to specify a custom status dependent on its own criteria. Completely overrides the default inherited status. + +Signature: + +```typescript +set(status$: Observable): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| status$ | Observable<ServiceStatus> | | + +Returns: + +`void` + +## Remarks + +See the [StatusServiceSetup.derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md) API for leveraging the default status calculation that is provided by Core. + diff --git a/rfcs/text/0010_service_status.md b/rfcs/text/0010_service_status.md index ded594930a367..76195c4f1ab89 100644 --- a/rfcs/text/0010_service_status.md +++ b/rfcs/text/0010_service_status.md @@ -137,7 +137,7 @@ interface StatusSetup { * Current status for all dependencies of the current plugin. * Each key of the `Record` is a plugin id. */ - plugins$: Observable>; + dependencies$: Observable>; /** * The status of this plugin as derived from its dependencies. diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index ba3eb28f90c5c..6e6d5cfc24340 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -311,6 +311,17 @@ export class LegacyService implements CoreService { status: { core$: setupDeps.core.status.core$, overall$: setupDeps.core.status.overall$, + set: () => { + throw new Error(`core.status.set is unsupported in legacy`); + }, + // @ts-expect-error + get dependencies$() { + throw new Error(`core.status.dependencies$ is unsupported in legacy`); + }, + // @ts-expect-error + get derivedStatus$() { + throw new Error(`core.status.derivedStatus$ is unsupported in legacy`); + }, }, uiSettings: { register: setupDeps.core.uiSettings.register, diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 5c389855d9ea2..af0b0e19b3227 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -185,6 +185,9 @@ export function createPluginSetupContext( status: { core$: deps.status.core$, overall$: deps.status.overall$, + set: deps.status.plugins.set.bind(null, plugin.name), + dependencies$: deps.status.plugins.getDependenciesStatus$(plugin.name), + derivedStatus$: deps.status.plugins.getDerivedStatus$(plugin.name), }, uiSettings: { register: deps.uiSettings.register, diff --git a/src/core/server/plugins/plugins_system.test.ts b/src/core/server/plugins/plugins_system.test.ts index 7af77491df1ab..71ac31db13f92 100644 --- a/src/core/server/plugins/plugins_system.test.ts +++ b/src/core/server/plugins/plugins_system.test.ts @@ -100,15 +100,27 @@ test('getPluginDependencies returns dependency tree of symbols', () => { pluginsSystem.addPlugin(createPlugin('no-dep')); expect(pluginsSystem.getPluginDependencies()).toMatchInlineSnapshot(` - Map { - Symbol(plugin-a) => Array [ - Symbol(no-dep), - ], - Symbol(plugin-b) => Array [ - Symbol(plugin-a), - Symbol(no-dep), - ], - Symbol(no-dep) => Array [], + Object { + "asNames": Map { + "plugin-a" => Array [ + "no-dep", + ], + "plugin-b" => Array [ + "plugin-a", + "no-dep", + ], + "no-dep" => Array [], + }, + "asOpaqueIds": Map { + Symbol(plugin-a) => Array [ + Symbol(no-dep), + ], + Symbol(plugin-b) => Array [ + Symbol(plugin-a), + Symbol(no-dep), + ], + Symbol(no-dep) => Array [], + }, } `); }); diff --git a/src/core/server/plugins/plugins_system.ts b/src/core/server/plugins/plugins_system.ts index f5c1b35d678a3..b2acd9a6fd04b 100644 --- a/src/core/server/plugins/plugins_system.ts +++ b/src/core/server/plugins/plugins_system.ts @@ -20,10 +20,11 @@ import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { PluginWrapper } from './plugin'; -import { DiscoveredPlugin, PluginName, PluginOpaqueId } from './types'; +import { DiscoveredPlugin, PluginName } from './types'; import { createPluginSetupContext, createPluginStartContext } from './plugin_context'; import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; import { withTimeout } from '../../utils'; +import { PluginDependencies } from '.'; const Sec = 1000; /** @internal */ @@ -45,9 +46,19 @@ export class PluginsSystem { * @returns a ReadonlyMap of each plugin and an Array of its available dependencies * @internal */ - public getPluginDependencies(): ReadonlyMap { - // Return dependency map of opaque ids - return new Map( + public getPluginDependencies(): PluginDependencies { + const asNames = new Map( + [...this.plugins].map(([name, plugin]) => [ + plugin.name, + [ + ...new Set([ + ...plugin.requiredPlugins, + ...plugin.optionalPlugins.filter((optPlugin) => this.plugins.has(optPlugin)), + ]), + ].map((depId) => this.plugins.get(depId)!.name), + ]) + ); + const asOpaqueIds = new Map( [...this.plugins].map(([name, plugin]) => [ plugin.opaqueId, [ @@ -58,6 +69,8 @@ export class PluginsSystem { ].map((depId) => this.plugins.get(depId)!.opaqueId), ]) ); + + return { asNames, asOpaqueIds }; } public async setupPlugins(deps: PluginsServiceSetupDeps) { diff --git a/src/core/server/plugins/types.ts b/src/core/server/plugins/types.ts index eb2a9ca3daf5f..517261b5bc9bb 100644 --- a/src/core/server/plugins/types.ts +++ b/src/core/server/plugins/types.ts @@ -93,6 +93,12 @@ export type PluginName = string; /** @public */ export type PluginOpaqueId = symbol; +/** @internal */ +export interface PluginDependencies { + asNames: ReadonlyMap; + asOpaqueIds: ReadonlyMap; +} + /** * Describes the set of required and optional properties plugin can define in its * mandatory JSON manifest file. diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index b9f24a75c01d2..18a385c6994b8 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -120,9 +120,17 @@ export class KibanaMigrator { Array<{ status: string }> > { if (this.migrationResult === undefined || rerun) { - this.status$.next({ status: 'running' }); + // Reruns are only used by CI / EsArchiver. Publishing status updates on reruns results in slowing down CI + // unnecessarily, so we skip it in this case. + if (!rerun) { + this.status$.next({ status: 'running' }); + } + this.migrationResult = this.runMigrationsInternal().then((result) => { - this.status$.next({ status: 'completed', result }); + // Similar to above, don't publish status updates when rerunning in CI. + if (!rerun) { + this.status$.next({ status: 'completed', result }); + } return result; }); } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b86cc14636b8c..aef1bda9ccf4e 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2778,10 +2778,17 @@ export type SharedGlobalConfig = RecursiveReadonly<{ // @public export type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart, TStart]>; +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "ServiceStatusSetup" +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "ServiceStatusSetup" +// // @public export interface StatusServiceSetup { core$: Observable; + dependencies$: Observable>; + // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "StatusSetup" + derivedStatus$: Observable; overall$: Observable; + set(status$: Observable): void; } // @public @@ -2870,8 +2877,8 @@ export const validBodyOutput: readonly ["data", "stream"]; // // src/core/server/http/router/response.ts:316:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts // src/core/server/legacy/types.ts:135:16 - (ae-forgotten-export) The symbol "LegacyPluginSpec" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts -// src/core/server/plugins/types.ts:268:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:272:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:272:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:274:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts ``` diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 417f66a2988c2..8bf16d9130ef5 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -49,7 +49,7 @@ const rawConfigService = rawConfigServiceMock.create({}); beforeEach(() => { mockConfigService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true })); mockPluginsService.discover.mockResolvedValue({ - pluginTree: new Map(), + pluginTree: { asOpaqueIds: new Map(), asNames: new Map() }, uiPlugins: { internal: new Map(), public: new Map(), browserConfigs: new Map() }, }); }); @@ -98,7 +98,7 @@ test('injects legacy dependency to context#setup()', async () => { [pluginB, [pluginA]], ]); mockPluginsService.discover.mockResolvedValue({ - pluginTree: pluginDependencies, + pluginTree: { asOpaqueIds: pluginDependencies, asNames: new Map() }, uiPlugins: { internal: new Map(), public: new Map(), browserConfigs: new Map() }, }); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 278dd72d72bb1..a02b0f51b559f 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -121,10 +121,13 @@ export class Server { const contextServiceSetup = this.context.setup({ // We inject a fake "legacy plugin" with dependencies on every plugin so that legacy plugins: - // 1) Can access context from any NP plugin + // 1) Can access context from any KP plugin // 2) Can register context providers that will only be available to other legacy plugins and will not leak into // New Platform plugins. - pluginDependencies: new Map([...pluginTree, [this.legacy.legacyId, [...pluginTree.keys()]]]), + pluginDependencies: new Map([ + ...pluginTree.asOpaqueIds, + [this.legacy.legacyId, [...pluginTree.asOpaqueIds.keys()]], + ]), }); const auditTrailSetup = this.auditTrail.setup(); @@ -153,6 +156,7 @@ export class Server { const statusSetup = await this.status.setup({ elasticsearch: elasticsearchServiceSetup, + pluginDependencies: pluginTree.asNames, savedObjects: savedObjectsSetup, }); diff --git a/src/core/server/status/get_summary_status.test.ts b/src/core/server/status/get_summary_status.test.ts index 7516e82ee784d..d97083162b502 100644 --- a/src/core/server/status/get_summary_status.test.ts +++ b/src/core/server/status/get_summary_status.test.ts @@ -94,6 +94,38 @@ describe('getSummaryStatus', () => { describe('summary', () => { describe('when a single service is at highest level', () => { it('returns all information about that single service', () => { + expect( + getSummaryStatus( + Object.entries({ + s1: degraded, + s2: { + level: ServiceStatusLevels.unavailable, + summary: 'Lorem ipsum', + meta: { + custom: { data: 'here' }, + }, + }, + }) + ) + ).toEqual({ + level: ServiceStatusLevels.unavailable, + summary: '[s2]: Lorem ipsum', + detail: 'See the status page for more information', + meta: { + affectedServices: { + s2: { + level: ServiceStatusLevels.unavailable, + summary: 'Lorem ipsum', + meta: { + custom: { data: 'here' }, + }, + }, + }, + }, + }); + }); + + it('allows the single service to override the detail and documentationUrl fields', () => { expect( getSummaryStatus( Object.entries({ @@ -115,7 +147,17 @@ describe('getSummaryStatus', () => { detail: 'Vivamus pulvinar sem ac luctus ultrices.', documentationUrl: 'http://helpmenow.com/problem1', meta: { - custom: { data: 'here' }, + affectedServices: { + s2: { + level: ServiceStatusLevels.unavailable, + summary: 'Lorem ipsum', + detail: 'Vivamus pulvinar sem ac luctus ultrices.', + documentationUrl: 'http://helpmenow.com/problem1', + meta: { + custom: { data: 'here' }, + }, + }, + }, }, }); }); diff --git a/src/core/server/status/get_summary_status.ts b/src/core/server/status/get_summary_status.ts index 748a54f0bf8bb..8d97cdbd9b15b 100644 --- a/src/core/server/status/get_summary_status.ts +++ b/src/core/server/status/get_summary_status.ts @@ -23,62 +23,60 @@ import { ServiceStatus, ServiceStatusLevels, ServiceStatusLevel } from './types' * Returns a single {@link ServiceStatus} that summarizes the most severe status level from a group of statuses. * @param statuses */ -export const getSummaryStatus = (statuses: Array<[string, ServiceStatus]>): ServiceStatus => { - const grouped = groupByLevel(statuses); - const highestSeverityLevel = getHighestSeverityLevel(grouped.keys()); - const highestSeverityGroup = grouped.get(highestSeverityLevel)!; +export const getSummaryStatus = ( + statuses: Array<[string, ServiceStatus]>, + { allAvailableSummary = `All services are available` }: { allAvailableSummary?: string } = {} +): ServiceStatus => { + const { highestLevel, highestStatuses } = highestLevelSummary(statuses); - if (highestSeverityLevel === ServiceStatusLevels.available) { + if (highestLevel === ServiceStatusLevels.available) { return { level: ServiceStatusLevels.available, - summary: `All services are available`, + summary: allAvailableSummary, }; - } else if (highestSeverityGroup.size === 1) { - const [serviceName, status] = [...highestSeverityGroup.entries()][0]; + } else if (highestStatuses.length === 1) { + const [serviceName, status] = highestStatuses[0]! as [string, ServiceStatus]; return { ...status, summary: `[${serviceName}]: ${status.summary!}`, + // TODO: include URL to status page + detail: status.detail ?? `See the status page for more information`, + meta: { + affectedServices: { [serviceName]: status }, + }, }; } else { return { - level: highestSeverityLevel, - summary: `[${highestSeverityGroup.size}] services are ${highestSeverityLevel.toString()}`, + level: highestLevel, + summary: `[${highestStatuses.length}] services are ${highestLevel.toString()}`, // TODO: include URL to status page detail: `See the status page for more information`, meta: { - affectedServices: Object.fromEntries([...highestSeverityGroup]), + affectedServices: Object.fromEntries(highestStatuses), }, }; } }; -const groupByLevel = ( - statuses: Array<[string, ServiceStatus]> -): Map> => { - const byLevel = new Map>(); +type StatusPair = [string, ServiceStatus]; - for (const [serviceName, status] of statuses) { - let levelMap = byLevel.get(status.level); - if (!levelMap) { - levelMap = new Map(); - byLevel.set(status.level, levelMap); - } +const highestLevelSummary = ( + statuses: StatusPair[] +): { highestLevel: ServiceStatusLevel; highestStatuses: StatusPair[] } => { + let highestLevel: ServiceStatusLevel = ServiceStatusLevels.available; + let highestStatuses: StatusPair[] = []; - levelMap.set(serviceName, status); + for (const pair of statuses) { + if (pair[1].level === highestLevel) { + highestStatuses.push(pair); + } else if (pair[1].level > highestLevel) { + highestLevel = pair[1].level; + highestStatuses = [pair]; + } } - return byLevel; -}; - -const getHighestSeverityLevel = (levels: Iterable): ServiceStatusLevel => { - const sorted = [...levels].sort((a, b) => { - if (a < b) { - return -1; - } else if (a > b) { - return 1; - } else { - return 0; - } - }); - return sorted[sorted.length - 1] ?? ServiceStatusLevels.available; + return { + highestLevel, + highestStatuses, + }; }; diff --git a/src/core/server/status/plugins_status.test.ts b/src/core/server/status/plugins_status.test.ts new file mode 100644 index 0000000000000..a75dc8c283698 --- /dev/null +++ b/src/core/server/status/plugins_status.test.ts @@ -0,0 +1,338 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginName } from '../plugins'; +import { PluginsStatusService } from './plugins_status'; +import { of, Observable, BehaviorSubject } from 'rxjs'; +import { ServiceStatusLevels, CoreStatus, ServiceStatus } from './types'; +import { first } from 'rxjs/operators'; +import { ServiceStatusLevelSnapshotSerializer } from './test_utils'; + +expect.addSnapshotSerializer(ServiceStatusLevelSnapshotSerializer); + +describe('PluginStatusService', () => { + const coreAllAvailable$: Observable = of({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'elasticsearch avail' }, + savedObjects: { level: ServiceStatusLevels.available, summary: 'savedObjects avail' }, + }); + const coreOneDegraded$: Observable = of({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'elasticsearch avail' }, + savedObjects: { level: ServiceStatusLevels.degraded, summary: 'savedObjects degraded' }, + }); + const coreOneCriticalOneDegraded$: Observable = of({ + elasticsearch: { level: ServiceStatusLevels.critical, summary: 'elasticsearch critical' }, + savedObjects: { level: ServiceStatusLevels.degraded, summary: 'savedObjects degraded' }, + }); + const pluginDependencies: Map = new Map([ + ['a', []], + ['b', ['a']], + ['c', ['a', 'b']], + ]); + + describe('getDerivedStatus$', () => { + it(`defaults to core's most severe status`, async () => { + const serviceAvailable = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies, + }); + expect(await serviceAvailable.getDerivedStatus$('a').pipe(first()).toPromise()).toEqual({ + level: ServiceStatusLevels.available, + summary: 'All dependencies are available', + }); + + const serviceDegraded = new PluginsStatusService({ + core$: coreOneDegraded$, + pluginDependencies, + }); + expect(await serviceDegraded.getDerivedStatus$('a').pipe(first()).toPromise()).toEqual({ + level: ServiceStatusLevels.degraded, + summary: '[savedObjects]: savedObjects degraded', + detail: 'See the status page for more information', + meta: expect.any(Object), + }); + + const serviceCritical = new PluginsStatusService({ + core$: coreOneCriticalOneDegraded$, + pluginDependencies, + }); + expect(await serviceCritical.getDerivedStatus$('a').pipe(first()).toPromise()).toEqual({ + level: ServiceStatusLevels.critical, + summary: '[elasticsearch]: elasticsearch critical', + detail: 'See the status page for more information', + meta: expect.any(Object), + }); + }); + + it(`provides a summary status when core and dependencies are at same severity level`, async () => { + const service = new PluginsStatusService({ core$: coreOneDegraded$, pluginDependencies }); + service.set('a', of({ level: ServiceStatusLevels.degraded, summary: 'a is degraded' })); + expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({ + level: ServiceStatusLevels.degraded, + summary: '[2] services are degraded', + detail: 'See the status page for more information', + meta: expect.any(Object), + }); + }); + + it(`allows dependencies status to take precedence over lower severity core statuses`, async () => { + const service = new PluginsStatusService({ core$: coreOneDegraded$, pluginDependencies }); + service.set('a', of({ level: ServiceStatusLevels.unavailable, summary: 'a is not working' })); + expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({ + level: ServiceStatusLevels.unavailable, + summary: '[a]: a is not working', + detail: 'See the status page for more information', + meta: expect.any(Object), + }); + }); + + it(`allows core status to take precedence over lower severity dependencies statuses`, async () => { + const service = new PluginsStatusService({ + core$: coreOneCriticalOneDegraded$, + pluginDependencies, + }); + service.set('a', of({ level: ServiceStatusLevels.unavailable, summary: 'a is not working' })); + expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({ + level: ServiceStatusLevels.critical, + summary: '[elasticsearch]: elasticsearch critical', + detail: 'See the status page for more information', + meta: expect.any(Object), + }); + }); + + it(`allows a severe dependency status to take precedence over a less severe dependency status`, async () => { + const service = new PluginsStatusService({ core$: coreOneDegraded$, pluginDependencies }); + service.set('a', of({ level: ServiceStatusLevels.degraded, summary: 'a is degraded' })); + service.set('b', of({ level: ServiceStatusLevels.unavailable, summary: 'b is not working' })); + expect(await service.getDerivedStatus$('c').pipe(first()).toPromise()).toEqual({ + level: ServiceStatusLevels.unavailable, + summary: '[b]: b is not working', + detail: 'See the status page for more information', + meta: expect.any(Object), + }); + }); + }); + + describe('getAll$', () => { + it('defaults to empty record if no plugins', async () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies: new Map(), + }); + expect(await service.getAll$().pipe(first()).toPromise()).toEqual({}); + }); + + it('defaults to core status when no plugin statuses are set', async () => { + const serviceAvailable = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies, + }); + expect(await serviceAvailable.getAll$().pipe(first()).toPromise()).toEqual({ + a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' }, + b: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' }, + c: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' }, + }); + + const serviceDegraded = new PluginsStatusService({ + core$: coreOneDegraded$, + pluginDependencies, + }); + expect(await serviceDegraded.getAll$().pipe(first()).toPromise()).toEqual({ + a: { + level: ServiceStatusLevels.degraded, + summary: '[savedObjects]: savedObjects degraded', + detail: 'See the status page for more information', + meta: expect.any(Object), + }, + b: { + level: ServiceStatusLevels.degraded, + summary: '[2] services are degraded', + detail: 'See the status page for more information', + meta: expect.any(Object), + }, + c: { + level: ServiceStatusLevels.degraded, + summary: '[3] services are degraded', + detail: 'See the status page for more information', + meta: expect.any(Object), + }, + }); + + const serviceCritical = new PluginsStatusService({ + core$: coreOneCriticalOneDegraded$, + pluginDependencies, + }); + expect(await serviceCritical.getAll$().pipe(first()).toPromise()).toEqual({ + a: { + level: ServiceStatusLevels.critical, + summary: '[elasticsearch]: elasticsearch critical', + detail: 'See the status page for more information', + meta: expect.any(Object), + }, + b: { + level: ServiceStatusLevels.critical, + summary: '[2] services are critical', + detail: 'See the status page for more information', + meta: expect.any(Object), + }, + c: { + level: ServiceStatusLevels.critical, + summary: '[3] services are critical', + detail: 'See the status page for more information', + meta: expect.any(Object), + }, + }); + }); + + it('uses the manually set status level if plugin specifies one', async () => { + const service = new PluginsStatusService({ core$: coreOneDegraded$, pluginDependencies }); + service.set('a', of({ level: ServiceStatusLevels.available, summary: 'a status' })); + + expect(await service.getAll$().pipe(first()).toPromise()).toEqual({ + a: { level: ServiceStatusLevels.available, summary: 'a status' }, // a is available depsite savedObjects being degraded + b: { + level: ServiceStatusLevels.degraded, + summary: '[savedObjects]: savedObjects degraded', + detail: 'See the status page for more information', + meta: expect.any(Object), + }, + c: { + level: ServiceStatusLevels.degraded, + summary: '[2] services are degraded', + detail: 'See the status page for more information', + meta: expect.any(Object), + }, + }); + }); + + it('updates when a new plugin status observable is set', async () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies: new Map([['a', []]]), + }); + const statusUpdates: Array> = []; + const subscription = service + .getAll$() + .subscribe((pluginStatuses) => statusUpdates.push(pluginStatuses)); + + service.set('a', of({ level: ServiceStatusLevels.degraded, summary: 'a degraded' })); + service.set('a', of({ level: ServiceStatusLevels.unavailable, summary: 'a unavailable' })); + service.set('a', of({ level: ServiceStatusLevels.available, summary: 'a available' })); + subscription.unsubscribe(); + + expect(statusUpdates).toEqual([ + { a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' } }, + { a: { level: ServiceStatusLevels.degraded, summary: 'a degraded' } }, + { a: { level: ServiceStatusLevels.unavailable, summary: 'a unavailable' } }, + { a: { level: ServiceStatusLevels.available, summary: 'a available' } }, + ]); + }); + }); + + describe('getDependenciesStatus$', () => { + it('only includes dependencies of specified plugin', async () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies, + }); + expect(await service.getDependenciesStatus$('a').pipe(first()).toPromise()).toEqual({}); + expect(await service.getDependenciesStatus$('b').pipe(first()).toPromise()).toEqual({ + a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' }, + }); + expect(await service.getDependenciesStatus$('c').pipe(first()).toPromise()).toEqual({ + a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' }, + b: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' }, + }); + }); + + it('uses the manually set status level if plugin specifies one', async () => { + const service = new PluginsStatusService({ core$: coreOneDegraded$, pluginDependencies }); + service.set('a', of({ level: ServiceStatusLevels.available, summary: 'a status' })); + + expect(await service.getDependenciesStatus$('c').pipe(first()).toPromise()).toEqual({ + a: { level: ServiceStatusLevels.available, summary: 'a status' }, // a is available depsite savedObjects being degraded + b: { + level: ServiceStatusLevels.degraded, + summary: '[savedObjects]: savedObjects degraded', + detail: 'See the status page for more information', + meta: expect.any(Object), + }, + }); + }); + + it('throws error if unknown plugin passed', () => { + const service = new PluginsStatusService({ core$: coreAllAvailable$, pluginDependencies }); + expect(() => { + service.getDependenciesStatus$('dont-exist'); + }).toThrowError(); + }); + + it('debounces events in quick succession', async () => { + const service = new PluginsStatusService({ + core$: coreAllAvailable$, + pluginDependencies, + }); + const available: ServiceStatus = { + level: ServiceStatusLevels.available, + summary: 'a available', + }; + const degraded: ServiceStatus = { + level: ServiceStatusLevels.degraded, + summary: 'a degraded', + }; + const pluginA$ = new BehaviorSubject(available); + service.set('a', pluginA$); + + const statusUpdates: Array> = []; + const subscription = service + .getDependenciesStatus$('b') + .subscribe((status) => statusUpdates.push(status)); + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + pluginA$.next(degraded); + pluginA$.next(available); + pluginA$.next(degraded); + pluginA$.next(available); + pluginA$.next(degraded); + pluginA$.next(available); + pluginA$.next(degraded); + // Waiting for the debounce timeout should cut a new update + await delay(500); + pluginA$.next(available); + await delay(500); + subscription.unsubscribe(); + + expect(statusUpdates).toMatchInlineSnapshot(` + Array [ + Object { + "a": Object { + "level": degraded, + "summary": "a degraded", + }, + }, + Object { + "a": Object { + "level": available, + "summary": "a available", + }, + }, + ] + `); + }); + }); +}); diff --git a/src/core/server/status/plugins_status.ts b/src/core/server/status/plugins_status.ts new file mode 100644 index 0000000000000..113d59b327c11 --- /dev/null +++ b/src/core/server/status/plugins_status.ts @@ -0,0 +1,98 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BehaviorSubject, Observable, combineLatest, of } from 'rxjs'; +import { map, distinctUntilChanged, switchMap, debounceTime } from 'rxjs/operators'; +import { isDeepStrictEqual } from 'util'; + +import { PluginName } from '../plugins'; +import { ServiceStatus, CoreStatus } from './types'; +import { getSummaryStatus } from './get_summary_status'; + +interface Deps { + core$: Observable; + pluginDependencies: ReadonlyMap; +} + +export class PluginsStatusService { + private readonly pluginStatuses = new Map>(); + private readonly update$ = new BehaviorSubject(true); + constructor(private readonly deps: Deps) {} + + public set(plugin: PluginName, status$: Observable) { + this.pluginStatuses.set(plugin, status$); + this.update$.next(true); // trigger all existing Observables to update from the new source Observable + } + + public getAll$(): Observable> { + return this.getPluginStatuses$([...this.deps.pluginDependencies.keys()]); + } + + public getDependenciesStatus$(plugin: PluginName): Observable> { + const dependencies = this.deps.pluginDependencies.get(plugin); + if (!dependencies) { + throw new Error(`Unknown plugin: ${plugin}`); + } + + return this.getPluginStatuses$(dependencies).pipe( + // Prevent many emissions at once from dependency status resolution from making this too noisy + debounceTime(500) + ); + } + + public getDerivedStatus$(plugin: PluginName): Observable { + return combineLatest([this.deps.core$, this.getDependenciesStatus$(plugin)]).pipe( + map(([coreStatus, pluginStatuses]) => { + return getSummaryStatus( + [...Object.entries(coreStatus), ...Object.entries(pluginStatuses)], + { + allAvailableSummary: `All dependencies are available`, + } + ); + }) + ); + } + + private getPluginStatuses$(plugins: PluginName[]): Observable> { + if (plugins.length === 0) { + return of({}); + } + + return this.update$.pipe( + switchMap(() => { + const pluginStatuses = plugins + .map( + (depName) => + [depName, this.pluginStatuses.get(depName) ?? this.getDerivedStatus$(depName)] as [ + PluginName, + Observable + ] + ) + .map(([pName, status$]) => + status$.pipe(map((status) => [pName, status] as [PluginName, ServiceStatus])) + ); + + return combineLatest(pluginStatuses).pipe( + map((statuses) => Object.fromEntries(statuses)), + distinctUntilChanged(isDeepStrictEqual) + ); + }) + ); + } +} diff --git a/src/core/server/status/status_service.mock.ts b/src/core/server/status/status_service.mock.ts index 47ef8659b4079..42b3eecdca310 100644 --- a/src/core/server/status/status_service.mock.ts +++ b/src/core/server/status/status_service.mock.ts @@ -40,6 +40,9 @@ const createSetupContractMock = () => { const setupContract: jest.Mocked = { core$: new BehaviorSubject(availableCoreStatus), overall$: new BehaviorSubject(available), + set: jest.fn(), + dependencies$: new BehaviorSubject({}), + derivedStatus$: new BehaviorSubject(available), }; return setupContract; @@ -50,6 +53,11 @@ const createInternalSetupContractMock = () => { core$: new BehaviorSubject(availableCoreStatus), overall$: new BehaviorSubject(available), isStatusPageAnonymous: jest.fn().mockReturnValue(false), + plugins: { + set: jest.fn(), + getDependenciesStatus$: jest.fn(), + getDerivedStatus$: jest.fn(), + }, }; return setupContract; diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts index 863fe34e8ecea..dcb1e0a559f5d 100644 --- a/src/core/server/status/status_service.test.ts +++ b/src/core/server/status/status_service.test.ts @@ -34,6 +34,7 @@ describe('StatusService', () => { service = new StatusService(mockCoreContext.create()); }); + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const available: ServiceStatus = { level: ServiceStatusLevels.available, summary: 'Available', @@ -53,6 +54,7 @@ describe('StatusService', () => { savedObjects: { status$: of(degraded), }, + pluginDependencies: new Map(), }); expect(await setup.core$.pipe(first()).toPromise()).toEqual({ elasticsearch: available, @@ -68,6 +70,7 @@ describe('StatusService', () => { savedObjects: { status$: of(degraded), }, + pluginDependencies: new Map(), }); const subResult1 = await setup.core$.pipe(first()).toPromise(); const subResult2 = await setup.core$.pipe(first()).toPromise(); @@ -96,6 +99,7 @@ describe('StatusService', () => { savedObjects: { status$: savedObjects$, }, + pluginDependencies: new Map(), }); const statusUpdates: CoreStatus[] = []; @@ -158,6 +162,7 @@ describe('StatusService', () => { savedObjects: { status$: of(degraded), }, + pluginDependencies: new Map(), }); expect(await setup.overall$.pipe(first()).toPromise()).toMatchObject({ level: ServiceStatusLevels.degraded, @@ -173,6 +178,7 @@ describe('StatusService', () => { savedObjects: { status$: of(degraded), }, + pluginDependencies: new Map(), }); const subResult1 = await setup.overall$.pipe(first()).toPromise(); const subResult2 = await setup.overall$.pipe(first()).toPromise(); @@ -201,26 +207,95 @@ describe('StatusService', () => { savedObjects: { status$: savedObjects$, }, + pluginDependencies: new Map(), }); const statusUpdates: ServiceStatus[] = []; const subscription = setup.overall$.subscribe((status) => statusUpdates.push(status)); + // Wait for timers to ensure that duplicate events are still filtered out regardless of debouncing. elasticsearch$.next(available); + await delay(500); elasticsearch$.next(available); + await delay(500); elasticsearch$.next({ level: ServiceStatusLevels.available, summary: `Wow another summary`, }); + await delay(500); savedObjects$.next(degraded); + await delay(500); savedObjects$.next(available); + await delay(500); savedObjects$.next(available); + await delay(500); subscription.unsubscribe(); expect(statusUpdates).toMatchInlineSnapshot(` Array [ Object { + "detail": "See the status page for more information", "level": degraded, + "meta": Object { + "affectedServices": Object { + "savedObjects": Object { + "level": degraded, + "summary": "This is degraded!", + }, + }, + }, + "summary": "[savedObjects]: This is degraded!", + }, + Object { + "level": available, + "summary": "All services are available", + }, + ] + `); + }); + + it('debounces events in quick succession', async () => { + const savedObjects$ = new BehaviorSubject(available); + const setup = await service.setup({ + elasticsearch: { + status$: new BehaviorSubject(available), + }, + savedObjects: { + status$: savedObjects$, + }, + pluginDependencies: new Map(), + }); + + const statusUpdates: ServiceStatus[] = []; + const subscription = setup.overall$.subscribe((status) => statusUpdates.push(status)); + + // All of these should debounced into a single `available` status + savedObjects$.next(degraded); + savedObjects$.next(available); + savedObjects$.next(degraded); + savedObjects$.next(available); + savedObjects$.next(degraded); + savedObjects$.next(available); + savedObjects$.next(degraded); + // Waiting for the debounce timeout should cut a new update + await delay(500); + savedObjects$.next(available); + await delay(500); + subscription.unsubscribe(); + + expect(statusUpdates).toMatchInlineSnapshot(` + Array [ + Object { + "detail": "See the status page for more information", + "level": degraded, + "meta": Object { + "affectedServices": Object { + "savedObjects": Object { + "level": degraded, + "summary": "This is degraded!", + }, + }, + }, "summary": "[savedObjects]: This is degraded!", }, Object { diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index aea335e64babf..8fe65eddb61d3 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -18,7 +18,7 @@ */ import { Observable, combineLatest } from 'rxjs'; -import { map, distinctUntilChanged, shareReplay, take } from 'rxjs/operators'; +import { map, distinctUntilChanged, shareReplay, take, debounceTime } from 'rxjs/operators'; import { isDeepStrictEqual } from 'util'; import { CoreService } from '../../types'; @@ -26,13 +26,16 @@ import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { InternalElasticsearchServiceSetup } from '../elasticsearch'; import { InternalSavedObjectsServiceSetup } from '../saved_objects'; +import { PluginName } from '../plugins'; import { config, StatusConfigType } from './status_config'; import { ServiceStatus, CoreStatus, InternalStatusServiceSetup } from './types'; import { getSummaryStatus } from './get_summary_status'; +import { PluginsStatusService } from './plugins_status'; interface SetupDeps { elasticsearch: Pick; + pluginDependencies: ReadonlyMap; savedObjects: Pick; } @@ -40,26 +43,44 @@ export class StatusService implements CoreService { private readonly logger: Logger; private readonly config$: Observable; + private pluginsStatus?: PluginsStatusService; + constructor(coreContext: CoreContext) { this.logger = coreContext.logger.get('status'); this.config$ = coreContext.configService.atPath(config.path); } - public async setup(core: SetupDeps) { + public async setup({ elasticsearch, pluginDependencies, savedObjects }: SetupDeps) { const statusConfig = await this.config$.pipe(take(1)).toPromise(); - const core$ = this.setupCoreStatus(core); - const overall$: Observable = core$.pipe( - map((coreStatus) => { - const summary = getSummaryStatus(Object.entries(coreStatus)); + const core$ = this.setupCoreStatus({ elasticsearch, savedObjects }); + this.pluginsStatus = new PluginsStatusService({ core$, pluginDependencies }); + + const overall$: Observable = combineLatest( + core$, + this.pluginsStatus.getAll$() + ).pipe( + // Prevent many emissions at once from dependency status resolution from making this too noisy + debounceTime(500), + map(([coreStatus, pluginsStatus]) => { + const summary = getSummaryStatus([ + ...Object.entries(coreStatus), + ...Object.entries(pluginsStatus), + ]); this.logger.debug(`Recalculated overall status`, { status: summary }); return summary; }), - distinctUntilChanged(isDeepStrictEqual) + distinctUntilChanged(isDeepStrictEqual), + shareReplay(1) ); return { core$, overall$, + plugins: { + set: this.pluginsStatus.set.bind(this.pluginsStatus), + getDependenciesStatus$: this.pluginsStatus.getDependenciesStatus$.bind(this.pluginsStatus), + getDerivedStatus$: this.pluginsStatus.getDerivedStatus$.bind(this.pluginsStatus), + }, isStatusPageAnonymous: () => statusConfig.allowAnonymous, }; } @@ -68,7 +89,10 @@ export class StatusService implements CoreService { public stop() {} - private setupCoreStatus({ elasticsearch, savedObjects }: SetupDeps): Observable { + private setupCoreStatus({ + elasticsearch, + savedObjects, + }: Pick): Observable { return combineLatest([elasticsearch.status$, savedObjects.status$]).pipe( map(([elasticsearchStatus, savedObjectsStatus]) => ({ elasticsearch: elasticsearchStatus, diff --git a/src/core/server/status/types.ts b/src/core/server/status/types.ts index 2ecf11deb2960..f884b80316fa8 100644 --- a/src/core/server/status/types.ts +++ b/src/core/server/status/types.ts @@ -19,6 +19,7 @@ import { Observable } from 'rxjs'; import { deepFreeze } from '../../utils'; +import { PluginName } from '../plugins'; /** * The current status of a service at a point in time. @@ -116,6 +117,60 @@ export interface CoreStatus { /** * API for accessing status of Core and this plugin's dependencies as well as for customizing this plugin's status. + * + * @remarks + * By default, a plugin inherits it's current status from the most severe status level of any Core services and any + * plugins that it depends on. This default status is available on the + * {@link ServiceStatusSetup.derivedStatus$ | core.status.derviedStatus$} API. + * + * Plugins may customize their status calculation by calling the {@link ServiceStatusSetup.set | core.status.set} API + * with an Observable. Within this Observable, a plugin may choose to only depend on the status of some of its + * dependencies, to ignore severe status levels of particular Core services they are not concerned with, or to make its + * status dependent on other external services. + * + * @example + * Customize a plugin's status to only depend on the status of SavedObjects: + * ```ts + * core.status.set( + * core.status.core$.pipe( + * . map((coreStatus) => { + * return coreStatus.savedObjects; + * }) ; + * ); + * ); + * ``` + * + * @example + * Customize a plugin's status to include an external service: + * ```ts + * const externalStatus$ = interval(1000).pipe( + * switchMap(async () => { + * const resp = await fetch(`https://myexternaldep.com/_healthz`); + * const body = await resp.json(); + * if (body.ok) { + * return of({ level: ServiceStatusLevels.available, summary: 'External Service is up'}); + * } else { + * return of({ level: ServiceStatusLevels.available, summary: 'External Service is unavailable'}); + * } + * }), + * catchError((error) => { + * of({ level: ServiceStatusLevels.unavailable, summary: `External Service is down`, meta: { error }}) + * }) + * ); + * + * core.status.set( + * combineLatest([core.status.derivedStatus$, externalStatus$]).pipe( + * map(([derivedStatus, externalStatus]) => { + * if (externalStatus.level > derivedStatus) { + * return externalStatus; + * } else { + * return derivedStatus; + * } + * }) + * ) + * ); + * ``` + * * @public */ export interface StatusServiceSetup { @@ -134,9 +189,43 @@ export interface StatusServiceSetup { * only depend on the statuses of {@link StatusServiceSetup.core$ | Core} or their dependencies. */ overall$: Observable; + + /** + * Allows a plugin to specify a custom status dependent on its own criteria. + * Completely overrides the default inherited status. + * + * @remarks + * See the {@link StatusServiceSetup.derivedStatus$} API for leveraging the default status + * calculation that is provided by Core. + */ + set(status$: Observable): void; + + /** + * Current status for all plugins this plugin depends on. + * Each key of the `Record` is a plugin id. + */ + dependencies$: Observable>; + + /** + * The status of this plugin as derived from its dependencies. + * + * @remarks + * By default, plugins inherit this derived status from their dependencies. + * Calling {@link StatusSetup.set} overrides this default status. + * + * This may emit multliple times for a single status change event as propagates + * through the dependency tree + */ + derivedStatus$: Observable; } /** @internal */ -export interface InternalStatusServiceSetup extends StatusServiceSetup { +export interface InternalStatusServiceSetup extends Pick { isStatusPageAnonymous: () => boolean; + // Namespaced under `plugins` key to improve clarity that these are APIs for plugins specifically. + plugins: { + set(plugin: PluginName, status$: Observable): void; + getDependenciesStatus$(plugin: PluginName): Observable>; + getDerivedStatus$(plugin: PluginName): Observable; + }; } From 33bc4430276593ff77bd33d811e861c030fc62d3 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 9 Sep 2020 20:10:32 -0600 Subject: [PATCH 22/25] [Maps] convert ESAggSource to TS (#76999) * [Maps] convert ESAggSource to TS * one more rename * tslint fixes Co-authored-by: Elastic Machine --- .../maps/public/classes/joins/inner_join.js | 4 +- .../layers/vector_layer/vector_layer.js | 6 +- .../ems_file_source/ems_file_source.test.tsx | 6 +- .../ems_file_source/ems_file_source.tsx | 3 +- .../sources/es_agg_source/es_agg_source.d.ts | 29 ---------- .../{es_agg_source.js => es_agg_source.ts} | 58 +++++++++++++------ .../es_geo_grid_source.d.ts | 2 + .../es_geo_grid_source/es_geo_grid_source.js | 4 -- .../es_pew_pew_source/es_pew_pew_source.js | 4 -- .../es_search_source/es_search_source.js | 2 +- .../sources/es_term_source/es_term_source.js | 4 -- .../mvt_single_layer_vector_source.test.tsx | 4 +- .../mvt_single_layer_vector_source.tsx | 2 +- .../sources/vector_source/vector_source.d.ts | 4 +- .../sources/vector_source/vector_source.js | 2 +- .../metrics_editor/metrics_editor.tsx | 1 - 16 files changed, 56 insertions(+), 79 deletions(-) delete mode 100644 x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.d.ts rename x-pack/plugins/maps/public/classes/sources/es_agg_source/{es_agg_source.js => es_agg_source.ts} (56%) diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.js b/x-pack/plugins/maps/public/classes/joins/inner_join.js index 76afe2430b818..75bf59d9d6404 100644 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.js +++ b/x-pack/plugins/maps/public/classes/joins/inner_join.js @@ -94,8 +94,8 @@ export class InnerJoin { return this._descriptor; } - async filterAndFormatPropertiesForTooltip(properties) { - return await this._rightSource.filterAndFormatPropertiesToHtml(properties); + async getTooltipProperties(properties) { + return await this._rightSource.getTooltipProperties(properties); } getIndexPatternIds() { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js index c49d0044e6ad6..27c344b713a60 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.js @@ -949,13 +949,11 @@ export class VectorLayer extends AbstractLayer { async getPropertiesForTooltip(properties) { const vectorSource = this.getSource(); - let allProperties = await vectorSource.filterAndFormatPropertiesToHtml(properties); + let allProperties = await vectorSource.getTooltipProperties(properties); this._addJoinsToSourceTooltips(allProperties); for (let i = 0; i < this.getJoins().length; i++) { - const propsFromJoin = await this.getJoins()[i].filterAndFormatPropertiesForTooltip( - properties - ); + const propsFromJoin = await this.getJoins()[i].getTooltipProperties(properties); allProperties = [...allProperties, ...propsFromJoin]; } return allProperties; diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.test.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.test.tsx index c5d6ced76b5c0..674ee832daab9 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.test.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.test.tsx @@ -17,10 +17,10 @@ function makeEMSFileSource(tooltipProperties: string[]) { } describe('EMS file source', () => { - describe('filterAndFormatPropertiesToHtml', () => { + describe('getTooltipProperties', () => { it('should create tooltip-properties with human readable label', async () => { const mockEMSFileSource = makeEMSFileSource(['iso2']); - const out = await mockEMSFileSource.filterAndFormatPropertiesToHtml({ + const out = await mockEMSFileSource.getTooltipProperties({ iso2: 'US', }); @@ -33,7 +33,7 @@ describe('EMS file source', () => { it('should order tooltip-properties', async () => { const tooltipProperties = ['iso3', 'iso2', 'name']; const mockEMSFileSource = makeEMSFileSource(tooltipProperties); - const out = await mockEMSFileSource.filterAndFormatPropertiesToHtml({ + const out = await mockEMSFileSource.getTooltipProperties({ name: 'United States', iso3: 'USA', iso2: 'US', diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx index f55a7434d1217..5f73a9e23431b 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_file_source.tsx @@ -23,7 +23,6 @@ import { ITooltipProperty } from '../../tooltips/tooltip_property'; export interface IEmsFileSource extends IVectorSource { getEmsFieldLabel(emsFieldName: string): Promise; - createField({ fieldName }: { fieldName: string }): IField; } export const sourceTitle = i18n.translate('xpack.maps.source.emsFileTitle', { @@ -168,7 +167,7 @@ export class EMSFileSource extends AbstractVectorSource implements IEmsFileSourc return this._tooltipFields.length > 0; } - async filterAndFormatPropertiesToHtml(properties: unknown): Promise { + async getTooltipProperties(properties: unknown): Promise { const promises = this._tooltipFields.map((field) => { // @ts-ignore const value = properties[field.getName()]; diff --git a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.d.ts b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.d.ts deleted file mode 100644 index eb50cd7528c8b..0000000000000 --- a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { IESSource } from '../es_source'; -import { AbstractESSource } from '../es_source'; -import { AGG_TYPE } from '../../../../common/constants'; -import { IESAggField } from '../../fields/es_agg_field'; -import { AbstractESAggSourceDescriptor } from '../../../../common/descriptor_types'; - -export interface IESAggSource extends IESSource { - getAggKey(aggType: AGG_TYPE, fieldName: string): string; - getAggLabel(aggType: AGG_TYPE, fieldName: string): string; - getMetricFields(): IESAggField[]; - hasMatchingMetricField(fieldName: string): boolean; - getMetricFieldForName(fieldName: string): IESAggField | null; -} - -export class AbstractESAggSource extends AbstractESSource implements IESAggSource { - constructor(sourceDescriptor: AbstractESAggSourceDescriptor, inspectorAdapters: object); - - getAggKey(aggType: AGG_TYPE, fieldName: string): string; - getAggLabel(aggType: AGG_TYPE, fieldName: string): string; - getMetricFields(): IESAggField[]; - hasMatchingMetricField(fieldName: string): boolean; - getMetricFieldForName(fieldName: string): IESAggField | null; -} diff --git a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.js b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts similarity index 56% rename from x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.js rename to x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts index e20c509ccd4a2..a9c886617d3af 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts @@ -5,19 +5,38 @@ */ import { i18n } from '@kbn/i18n'; +import { Adapters } from 'src/plugins/inspector/public'; +import { GeoJsonProperties } from 'geojson'; +import { IESSource } from '../es_source'; import { AbstractESSource } from '../es_source'; import { esAggFieldsFactory } from '../../fields/es_agg_field'; import { AGG_TYPE, COUNT_PROP_LABEL, FIELD_ORIGIN } from '../../../../common/constants'; +import { IESAggField } from '../../fields/es_agg_field'; import { getSourceAggKey } from '../../../../common/get_agg_key'; +import { AbstractESAggSourceDescriptor, AggDescriptor } from '../../../../common/descriptor_types'; +import { IndexPattern } from '../../../../../../../src/plugins/data/public'; +import { IField } from '../../fields/field'; +import { ITooltipProperty } from '../../tooltips/tooltip_property'; export const DEFAULT_METRIC = { type: AGG_TYPE.COUNT }; +export interface IESAggSource extends IESSource { + getAggKey(aggType: AGG_TYPE, fieldName: string): string; + getAggLabel(aggType: AGG_TYPE, fieldName: string): string; + getMetricFields(): IESAggField[]; + hasMatchingMetricField(fieldName: string): boolean; + getMetricFieldForName(fieldName: string): IESAggField | null; + getValueAggsDsl(indexPattern: IndexPattern): { [key: string]: unknown }; +} + export class AbstractESAggSource extends AbstractESSource { - constructor(descriptor, inspectorAdapters) { + private readonly _metricFields: IESAggField[]; + + constructor(descriptor: AbstractESAggSourceDescriptor, inspectorAdapters: Adapters) { super(descriptor, inspectorAdapters); this._metricFields = []; - if (this._descriptor.metrics) { - this._descriptor.metrics.forEach((aggDescriptor) => { + if (descriptor.metrics) { + descriptor.metrics.forEach((aggDescriptor: AggDescriptor) => { this._metricFields.push( ...esAggFieldsFactory(aggDescriptor, this, this.getOriginForField()) ); @@ -25,30 +44,31 @@ export class AbstractESAggSource extends AbstractESSource { } } - getFieldByName(name) { - return this.getMetricFieldForName(name); + getFieldByName(fieldName: string) { + return this.getMetricFieldForName(fieldName); } - createField() { + createField({ fieldName }: { fieldName: string }): IField { throw new Error('Cannot create a new field from just a fieldname for an es_agg_source.'); } - hasMatchingMetricField(fieldName) { + hasMatchingMetricField(fieldName: string): boolean { const matchingField = this.getMetricFieldForName(fieldName); return !!matchingField; } - getMetricFieldForName(fieldName) { - return this.getMetricFields().find((metricField) => { + getMetricFieldForName(fieldName: string): IESAggField | null { + const targetMetricField = this.getMetricFields().find((metricField: IESAggField) => { return metricField.getName() === fieldName; }); + return targetMetricField ? targetMetricField : null; } getOriginForField() { return FIELD_ORIGIN.SOURCE; } - getMetricFields() { + getMetricFields(): IESAggField[] { const metrics = this._metricFields.filter((esAggField) => esAggField.isValid()); // Handle case where metrics is empty because older saved object state is empty array or there are no valid aggs. return metrics.length === 0 @@ -56,14 +76,14 @@ export class AbstractESAggSource extends AbstractESSource { : metrics; } - getAggKey(aggType, fieldName) { + getAggKey(aggType: AGG_TYPE, fieldName: string): string { return getSourceAggKey({ aggType, aggFieldName: fieldName, }); } - getAggLabel(aggType, fieldName) { + getAggLabel(aggType: AGG_TYPE, fieldName: string): string { switch (aggType) { case AGG_TYPE.COUNT: return COUNT_PROP_LABEL; @@ -81,8 +101,8 @@ export class AbstractESAggSource extends AbstractESSource { return this.getMetricFields(); } - getValueAggsDsl(indexPattern) { - const valueAggsDsl = {}; + getValueAggsDsl(indexPattern: IndexPattern) { + const valueAggsDsl: { [key: string]: unknown } = {}; this.getMetricFields().forEach((esAggMetric) => { const aggDsl = esAggMetric.getValueAggDsl(indexPattern); if (aggDsl) { @@ -92,9 +112,9 @@ export class AbstractESAggSource extends AbstractESSource { return valueAggsDsl; } - async filterAndFormatPropertiesToHtmlForMetricFields(properties) { - const metricFields = this.getMetricFields(); - const tooltipPropertiesPromises = []; + async getTooltipProperties(properties: GeoJsonProperties) { + const metricFields = await this.getFields(); + const promises: Array> = []; metricFields.forEach((metricField) => { let value; for (const key in properties) { @@ -105,9 +125,9 @@ export class AbstractESAggSource extends AbstractESSource { } const tooltipPromise = metricField.createTooltipProperty(value); - tooltipPropertiesPromises.push(tooltipPromise); + promises.push(tooltipPromise); }); - return await Promise.all(tooltipPropertiesPromises); + return await Promise.all(promises); } } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.d.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.d.ts index 51ee15e7ea5af..2ce4353fca13c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.d.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.d.ts @@ -7,6 +7,7 @@ import { AbstractESAggSource } from '../es_agg_source'; import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; import { GRID_RESOLUTION } from '../../../../common/constants'; +import { IField } from '../../fields/field'; export class ESGeoGridSource extends AbstractESAggSource { static createDescriptor({ @@ -21,4 +22,5 @@ export class ESGeoGridSource extends AbstractESAggSource { getFieldNames(): string[]; getGridResolution(): GRID_RESOLUTION; getGeoGridPrecision(zoom: number): number; + createField({ fieldName }: { fieldName: string }): IField; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js index a6322ff3ba784..aa167cb577672 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js @@ -321,10 +321,6 @@ export class ESGeoGridSource extends AbstractESAggSource { return true; } - async filterAndFormatPropertiesToHtml(properties) { - return await this.filterAndFormatPropertiesToHtmlForMetricFields(properties); - } - async getSupportedShapeTypes() { if (this._descriptor.requestType === RENDER_AS.GRID) { return [VECTOR_SHAPE_TYPE.POLYGON]; diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js index 92b0c717f6724..9ec54335d4e78 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js @@ -223,10 +223,6 @@ export class ESPewPewSource extends AbstractESAggSource { canFormatFeatureProperties() { return true; } - - async filterAndFormatPropertiesToHtml(properties) { - return await this.filterAndFormatPropertiesToHtmlForMetricFields(properties); - } } registerSource({ diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js index 7ac2738eaeb51..df83bd1cf5e60 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js @@ -438,7 +438,7 @@ export class ESSearchSource extends AbstractESSource { return properties; } - async filterAndFormatPropertiesToHtml(properties) { + async getTooltipProperties(properties) { const indexPattern = await this.getIndexPattern(); const propertyValues = await this._loadTooltipProperties( properties._id, diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.js b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.js index 8cc8dd5c4a080..b4ad256c1598a 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.js @@ -129,10 +129,6 @@ export class ESTermSource extends AbstractESAggSource { return `es_table ${this.getIndexPatternId()}`; } - async filterAndFormatPropertiesToHtml(properties) { - return await this.filterAndFormatPropertiesToHtmlForMetricFields(properties); - } - getFieldNames() { return this.getMetricFields().map((esAggMetricField) => esAggMetricField.getName()); } diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx index 4e9e1e9cd7680..48f7b30261f38 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx @@ -45,7 +45,7 @@ describe('canFormatFeatureProperties', () => { }); }); -describe('filterAndFormatPropertiesToHtml', () => { +describe('getTooltipProperties', () => { const descriptorWithFields = { ...descriptor, fields: [ @@ -67,7 +67,7 @@ describe('filterAndFormatPropertiesToHtml', () => { it('should get tooltipproperties', async () => { const source = new MVTSingleLayerVectorSource(descriptorWithFields); - const tooltipProperties = await source.filterAndFormatPropertiesToHtml({ + const tooltipProperties = await source.getTooltipProperties({ foo: 'bar', fooz: 123, }); diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx index 52dc89a6bba58..3e515613b3fd0 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx @@ -192,7 +192,7 @@ export class MVTSingleLayerVectorSource return false; } - async filterAndFormatPropertiesToHtml( + async getTooltipProperties( properties: GeoJsonProperties, featureId?: string | number ): Promise { diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts index fd9c179275444..a481e273bc33e 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts @@ -36,7 +36,7 @@ export type BoundsFilters = { }; export interface IVectorSource extends ISource { - filterAndFormatPropertiesToHtml(properties: GeoJsonProperties): Promise; + getTooltipProperties(properties: GeoJsonProperties): Promise; getBoundsForFilters( boundsFilters: BoundsFilters, registerCancelCallback: (requestToken: symbol, callback: () => void) => void @@ -58,7 +58,7 @@ export interface IVectorSource extends ISource { } export class AbstractVectorSource extends AbstractSource implements IVectorSource { - filterAndFormatPropertiesToHtml(properties: GeoJsonProperties): Promise; + getTooltipProperties(properties: GeoJsonProperties): Promise; getBoundsForFilters( boundsFilters: BoundsFilters, registerCancelCallback: (requestToken: symbol, callback: () => void) => void diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js index 98ed89a6ff0ad..9569b8626aabf 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js @@ -106,7 +106,7 @@ export class AbstractVectorSource extends AbstractSource { } // Allow source to filter and format feature properties before displaying to user - async filterAndFormatPropertiesToHtml(properties) { + async getTooltipProperties(properties) { const tooltipProperties = []; for (const key in properties) { if (key.startsWith('__kbn')) { diff --git a/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.tsx b/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.tsx index 17cfc5f62fee5..dae1f51469281 100644 --- a/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.tsx +++ b/x-pack/plugins/maps/public/components/metrics_editor/metrics_editor.tsx @@ -8,7 +8,6 @@ import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiComboBoxOptionOption, EuiSpacer, EuiTextAlign } from '@elastic/eui'; import { MetricEditor } from './metric_editor'; -// @ts-expect-error import { DEFAULT_METRIC } from '../../classes/sources/es_agg_source'; import { IFieldType } from '../../../../../../src/plugins/data/public'; import { AggDescriptor } from '../../../common/descriptor_types'; From f56fcb30556788b51385cd7c073dec78672407ff Mon Sep 17 00:00:00 2001 From: Constance Date: Wed, 9 Sep 2020 20:20:20 -0700 Subject: [PATCH 23/25] [Enterprise Search] Update shared API request handler (#77112) * Add user auth check for /ent/select redirects - Recent Enterprise Search CSRF changes have made it so redirects can occur to /ent/select and not just /login * Fix request.query typing - API endpoints passing in custom request.query params were seeing {} type errors - this change works around them * Add Accept and Content-Type JSON headers to Enterprise Search requests - Without the Accept header, Enterprise Search APIs will kick back a CSRF error - Without the Content-Type header, APIs will not load JSON bodies as parameters per Ruby on Rails docs --- .../enterprise_search/common/constants.ts | 5 +++- .../enterprise_search_request_handler.test.ts | 29 ++++++++++++------- .../lib/enterprise_search_request_handler.ts | 11 +++---- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 6e2f0c0f24b7a..c6ca0d532ce07 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -70,6 +70,9 @@ export const WORKPLACE_SEARCH_PLUGIN = { export const LICENSED_SUPPORT_URL = 'https://support.elastic.co'; -export const JSON_HEADER = { 'Content-Type': 'application/json' }; // This needs specific casing or Chrome throws a 415 error +export const JSON_HEADER = { + 'Content-Type': 'application/json', // This needs specific casing or Chrome throws a 415 error + Accept: 'application/json', // Required for Enterprise Search APIs +}; export const ENGINES_PAGE_SIZE = 10; 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 3f3f182433144..34f83ef3a3fd2 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,6 +5,7 @@ */ import { mockConfig, mockLogger } from '../__mocks__'; +import { JSON_HEADER } from '../../common/constants'; import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler'; @@ -150,18 +151,26 @@ describe('EnterpriseSearchRequestHandler', () => { ); }); - it('returns an error when user authentication to Enterprise Search fails', async () => { - EnterpriseSearchAPI.mockReturn({}, { url: 'http://localhost:3002/login' }); - const requestHandler = enterpriseSearchRequestHandler.createRequest({ - path: '/api/unauthenticated', + describe('user authentication errors', () => { + afterEach(async () => { + const requestHandler = enterpriseSearchRequestHandler.createRequest({ + path: '/api/unauthenticated', + }); + await makeAPICall(requestHandler); + + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/unauthenticated'); + expect(responseMock.customError).toHaveBeenCalledWith({ + body: 'Error connecting to Enterprise Search: Cannot authenticate Enterprise Search user', + statusCode: 502, + }); }); - await makeAPICall(requestHandler); - EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/unauthenticated'); + it('errors when redirected to /login', async () => { + EnterpriseSearchAPI.mockReturn({}, { url: 'http://localhost:3002/login' }); + }); - expect(responseMock.customError).toHaveBeenCalledWith({ - body: 'Error connecting to Enterprise Search: Cannot authenticate Enterprise Search user', - statusCode: 502, + it('errors when redirected to /ent/select', async () => { + EnterpriseSearchAPI.mockReturn({}, { url: 'http://localhost:3002/ent/select' }); }); }); }); @@ -185,7 +194,7 @@ const makeAPICall = (handler: Function, params = {}) => { const EnterpriseSearchAPI = { shouldHaveBeenCalledWith(expectedUrl: string, expectedParams = {}) { expect(fetchMock).toHaveBeenCalledWith(expectedUrl, { - headers: { Authorization: 'Basic 123' }, + headers: { Authorization: 'Basic 123', ...JSON_HEADER }, method: 'GET', body: undefined, ...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 8f31bd9063d4a..18f10c590847c 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 @@ -14,6 +14,7 @@ import { Logger, } from 'src/core/server'; import { ConfigType } from '../index'; +import { JSON_HEADER } from '../../common/constants'; interface IConstructorDependencies { config: ConfigType; @@ -25,7 +26,7 @@ interface IRequestParams { hasValidData?: (body?: ResponseBody) => boolean; } export interface IEnterpriseSearchRequestHandler { - createRequest(requestParams?: object): RequestHandler, unknown>; + createRequest(requestParams?: object): RequestHandler; } /** @@ -52,12 +53,12 @@ export class EnterpriseSearchRequestHandler { }: IRequestParams) { return async ( _context: RequestHandlerContext, - request: KibanaRequest, unknown>, + request: KibanaRequest, response: KibanaResponseFactory ) => { try { // Set up API URL - const queryParams = { ...request.query, ...params }; + const queryParams = { ...(request.query as object), ...params }; const queryString = !this.isEmptyObj(queryParams) ? `?${querystring.stringify(queryParams)}` : ''; @@ -65,7 +66,7 @@ export class EnterpriseSearchRequestHandler { // Set up API options const { method } = request.route; - const headers = { Authorization: request.headers.authorization as string }; + const headers = { Authorization: request.headers.authorization as string, ...JSON_HEADER }; const body = !this.isEmptyObj(request.body as object) ? JSON.stringify(request.body) : undefined; @@ -73,7 +74,7 @@ export class EnterpriseSearchRequestHandler { // Call the Enterprise Search API and pass back response to the front-end const apiResponse = await fetch(url, { method, headers, body }); - if (apiResponse.url.endsWith('/login')) { + if (apiResponse.url.endsWith('/login') || apiResponse.url.endsWith('/ent/select')) { throw new Error('Cannot authenticate Enterprise Search user'); } From 1dd4b65321570e8ce3df1b19a2fc7c85e6a4c25b Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 10 Sep 2020 10:15:45 +0200 Subject: [PATCH 24/25] clean up test (#76887) --- .../apps/management/_create_index_pattern_wizard.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.js index 9760527371408..8b11a02099f61 100644 --- a/test/functional/apps/management/_create_index_pattern_wizard.js +++ b/test/functional/apps/management/_create_index_pattern_wizard.js @@ -66,6 +66,18 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.createIndexPattern('alias1', false); }); + + after(async () => { + await es.transport.request({ + path: '/_aliases', + method: 'POST', + body: { actions: [{ remove: { index: 'blogs', alias: 'alias1' } }] }, + }); + await es.transport.request({ + path: '/blogs', + method: 'DELETE', + }); + }); }); }); } From 2f4d05939fb89edfd359d23c174b48eca779bf73 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 10 Sep 2020 10:25:25 +0200 Subject: [PATCH 25/25] [bugfix] Replace panel flyout opens 2 flyouts (#76931) * fix replace panel flyout opens 2 flyouts * fix sample action flyout --- .../application/actions/open_replace_panel_flyout.tsx | 3 ++- .../application/actions/replace_panel_flyout.tsx | 11 +++++------ .../public/tests/test_samples/hello_world_action.tsx | 11 ++++------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx b/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx index c676ca052d687..54a294fd2f4ac 100644 --- a/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx @@ -60,7 +60,8 @@ export async function openReplacePanelFlyout(options: { /> ), { - 'data-test-subj': 'replacePanelFlyout', + 'data-test-subj': 'dashboardReplacePanel', + ownFocus: true, } ); } diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx b/src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx index 0000f63c48c2d..4e228bc1a7a06 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx @@ -19,16 +19,15 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; -import _ from 'lodash'; -import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; +import { EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; import { NotificationsStart, Toast } from 'src/core/public'; import { DashboardPanelState } from '../embeddable'; import { - IContainer, - IEmbeddable, EmbeddableInput, EmbeddableOutput, EmbeddableStart, + IContainer, + IEmbeddable, SavedObjectEmbeddableInput, } from '../../embeddable_plugin'; @@ -122,7 +121,7 @@ export class ReplacePanelFlyout extends React.Component { const panelToReplace = 'Replace panel ' + this.props.panelToRemove.getTitle() + ' with:'; return ( - + <>

@@ -131,7 +130,7 @@ export class ReplacePanelFlyout extends React.Component { {savedObjectsFinder} - + ); } } diff --git a/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx b/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx index 8fff231a867bf..a4cfe172dd109 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx +++ b/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; -import { EuiFlyout, EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiFlyoutBody } from '@elastic/eui'; import { CoreStart } from 'src/core/public'; import { createAction, ActionByType } from '../../actions'; import { toMountPoint, reactToUiComponent } from '../../../../kibana_react/public'; @@ -49,14 +49,11 @@ export function createHelloWorldAction( getIconType: () => 'lock', MenuItem: UiMenuItem, execute: async () => { - const flyoutSession = overlays.openFlyout( - toMountPoint( - flyoutSession && flyoutSession.close()}> - Hello World, I am a hello world action! - - ), + overlays.openFlyout( + toMountPoint(Hello World, I am a hello world action!), { 'data-test-subj': 'helloWorldAction', + ownFocus: true, } ); },